TLS 1.3のハンドシェイクレコード復号——AEAD鍵導出とTLSInnerPlaintextの復元
Handshake Traffic Secretの導出によって、client_handshake_traffic_secret/server_handshake_traffic_secretという2つのSecretが手元に揃いました。ServerHelloより後のEncryptedExtensions・Certificate・CertificateVerify・FinishedといったメッセージはすべてこのSecretから導出した鍵でAES-128-GCM(AEAD)保護されています。この記事では、Secretから実際のAEAD鍵/IVを導出し、レコードを復号してTLSInnerPlaintextを復元するところまでを扱います。
Handshake Traffic SecretからAEAD鍵/IVを導出
RFC 8446 §7.3は、Traffic SecretからAEAD鍵とIVを導出する手順をHKDF-Expand-Labelのラベル"key"/"iv"として定義しています。コンテキストは空、出力長はAEADアルゴリズムが要求する長さ——TLS_AES_128_GCM_SHA256なら鍵16バイト・IV12バイトです。
// tcp-tls13/include/tcptls13/key_schedule.hpp
struct TrafficKeys
{
std::array<std::byte, 16> key;
std::array<std::byte, 12> iv;
};
std::expected<TrafficKeys, KeyScheduleError>
derive_traffic_keys(std::span<const std::byte> traffic_secret);// tcp-tls13/src/key_schedule.cpp
std::expected<TrafficKeys, KeyScheduleError>
derive_traffic_keys(std::span<const std::byte> traffic_secret)
{
auto key = hkdf_expand_label(traffic_secret, "key", std::span<const std::byte>(), 16);
if (!key)
{
return std::unexpected(key.error());
}
auto iv = hkdf_expand_label(traffic_secret, "iv", std::span<const std::byte>(), 12);
if (!iv)
{
return std::unexpected(iv.error());
}
TrafficKeys keys{};
std::ranges::copy(*key, keys.key.begin());
std::ranges::copy(*iv, keys.iv.begin());
return keys;
}client_handshake_traffic_secretとserver_handshake_traffic_secretはそれぞれ独立にこの関数へ渡され、クライアント/サーバそれぞれの書き込み方向の鍵ペアを作ります。TLS 1.3では読み書きの鍵が別々に存在するため、後述のRecordProtectionもクライアント用(送信の暗号化用)とサーバ用(受信の復号用)で別インスタンスとして持つことになります。
レコードごとのnonce——シーケンス番号とIVのXOR
AEADのnonceはレコードごとに変える必要があります。RFC 8446 §5.3は、方向ごとに64bitのシーケンス番号を0から数え、それをビッグエンディアンでIVの長さまで左側をゼロ埋めしたうえでIVとXORする、という構成を定めています。
// tcp-tls13/include/tcptls13/record_protection.hpp
class RecordProtection
{
public:
explicit RecordProtection(TrafficKeys keys);
std::expected<std::vector<std::byte>, RecordProtectionError> seal(
ContentType type,
std::span<const std::byte> plaintext
);
struct Opened
{
ContentType type;
std::vector<std::byte> plaintext;
};
std::expected<Opened, RecordProtectionError> open(
std::span<const std::byte> ciphertext
);
private:
std::array<std::byte, 12> next_nonce();
TrafficKeys keys_;
std::uint64_t sequence_number_ = 0;
};// tcp-tls13/src/record_protection.cpp
std::array<std::byte, 12> RecordProtection::next_nonce()
{
std::array<std::byte, 12> nonce = keys_.iv;
for (std::size_t i = 0; i < 8; ++i)
{
const auto shift = 8 * (7 - i);
nonce[4 + i] ^= static_cast<std::byte>((sequence_number_ >> shift) & 0xFF);
}
++sequence_number_;
return nonce;
}12バイトのIVのうち先頭4バイトは変化せず、後半8バイトにシーケンス番号がビッグエンディアンでXORされます。sequence_number_はseal/openが呼ばれるたびに1つずつ進み、RecordProtectionインスタンスごと(つまり方向ごと)に独立してカウントされます。最初のレコードは必ずシーケンス番号0——nonceがIVそのままになる、という仕様です。
AEAD暗号化——AES-128-GCMとAdditional Data
RFC 8446 §5.2は、暗号化前の構造をTLSInnerPlaintext、暗号化後をTLSCiphertextとして定義し、AEADの追加認証データ(AAD)を次のように定めています。
additional_data = TLSCiphertext.opaque_type ||
TLSCiphertext.legacy_record_version ||
TLSCiphertext.length外側のTLSレコードヘッダそのものがAADになる、という設計です。opaque_typeは暗号化されたレコードでは常にapplication_data(23)固定である点が重要で、これは後述するミドルボックス互換の話にも関わってきます。
// tcp-tls13/src/record_protection.cpp(無名namespace内)
std::array<std::byte, 5> build_additional_data(std::size_t encrypted_record_length)
{
std::array<std::byte, 5> aad{};
aad[0] = static_cast<std::byte>(ContentType::ApplicationData);
detail::write_uint16(std::span(aad).subspan<1, 2>(), LegacyRecordVersion);
detail::write_uint16(
std::span(aad).subspan<3, 2>(),
static_cast<std::uint16_t>(encrypted_record_length)
);
return aad;
}復号側open()はOpenSSLのEVP_CIPHER_CTXをAES-128-GCMで初期化し、AADをEVP_DecryptUpdate(出力先nullptr)で流し込んでから本体を復号、最後にEVP_CTRL_GCM_SET_TAGで受信したタグをセットしてEVP_DecryptFinal_exを呼びます。この最後の呼び出しが失敗を返せば、タグが一致しない——つまり改ざんまたは鍵/nonceの不一致です。
// tcp-tls13/src/record_protection.cpp
std::expected<RecordProtection::Opened, RecordProtectionError>
RecordProtection::open(std::span<const std::byte> ciphertext)
{
if (ciphertext.size() < GcmTagLength)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
const auto body = ciphertext.first(ciphertext.size() - GcmTagLength);
std::array<std::byte, GcmTagLength> tag{};
std::ranges::copy(ciphertext.last(GcmTagLength), tag.begin());
const auto aad = build_additional_data(ciphertext.size());
const auto nonce = next_nonce();
EvpCipherCtxPtr ctx(EVP_CIPHER_CTX_new());
if (!ctx
|| EVP_DecryptInit_ex(ctx.get(), EVP_aes_128_gcm(), nullptr, nullptr, nullptr) != 1
|| EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_SET_IVLEN, static_cast<int>(nonce.size()), nullptr) != 1
|| EVP_DecryptInit_ex(
ctx.get(), nullptr, nullptr,
reinterpret_cast<const unsigned char*>(keys_.key.data()),
reinterpret_cast<const unsigned char*>(nonce.data())
) != 1
)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
int out_len = 0;
if (EVP_DecryptUpdate(
ctx.get(), nullptr, &out_len,
reinterpret_cast<const unsigned char*>(aad.data()), static_cast<int>(aad.size())
) != 1)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
std::vector<std::byte> inner(body.size());
if (EVP_DecryptUpdate(
ctx.get(), reinterpret_cast<unsigned char*>(inner.data()), &out_len,
reinterpret_cast<const unsigned char*>(body.data()), static_cast<int>(body.size())
) != 1)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
if (EVP_CIPHER_CTX_ctrl(ctx.get(), EVP_CTRL_GCM_SET_TAG, static_cast<int>(tag.size()), tag.data()) != 1)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
int final_len = 0;
if (EVP_DecryptFinal_ex(ctx.get(), nullptr, &final_len) != 1)
{
return std::unexpected(RecordProtectionError::OpenFailed);
}
// TLSInnerPlaintextの復元(次節)
...
}seal()はこの逆で、EVP_EncryptInit_ex/EVP_EncryptUpdate/EVP_EncryptFinal_exの並びになり、EVP_CTRL_GCM_GET_TAGで計算済みタグを取り出して暗号文の末尾に付加します。
TLSInnerPlaintextの復元——末尾から辿るcontent type
復号して得られる平文は、生のハンドシェイクバイト列そのものではなく、末尾にcontent typeとパディングが付いたTLSInnerPlaintextです。
struct {
opaque content[TLSPlaintext.length];
ContentType type;
uint8 zeros[length_of_padding];
} TLSInnerPlaintext;RFC 8446 §5.4は、パディングを剥がす方法として「末尾から先頭に向かって走査し、最初に見つかったゼロでないバイトがcontent type」というアルゴリズムを定めています。今回の実装はレコードを送信する際にパディングを付加しない(パディング長は常に0)ため、seal()側は単純にcontent typeを1バイト追記するだけですが、open()側は相手が任意長のパディングを付けてくる可能性を考慮してこの走査が必要です。
// tcp-tls13/src/record_protection.cpp(open()の続き)
std::size_t content_type_index = inner.size();
while (content_type_index > 0 && inner[content_type_index - 1] == std::byte{0})
{
--content_type_index;
}
if (content_type_index == 0)
{
return std::unexpected(RecordProtectionError::InvalidInnerPlaintext);
}
--content_type_index;
return Opened{
.type = static_cast<ContentType>(inner[content_type_index]),
.plaintext = std::vector<std::byte>(inner.begin(), inner.begin() + content_type_index),
};HandshakeLayerを暗号化対応にする
TLSレコード層で実装したHandshakeLayerは、TLSレコードのContentTypeが直接Handshakeであることを前提にハンドシェイクメッセージの境界を再構成していました。暗号化開始後はこの前提が崩れます——TLSCiphertextの外側opaque_typeはミドルボックス互換のため常にapplication_data(23)に固定され、本当のcontent typeは復号後のTLSInnerPlaintextにしか現れません(RFC 8446 §5.2)。
HandshakeLayerに、送受信それぞれのRecordProtectionを受け取るコンストラクタを追加し、暗号化モードでは復号してから中身をバッファに積むようにします。
// tcp-tls13/include/tcptls13/handshake_layer.hpp
class HandshakeLayer
{
public:
explicit HandshakeLayer(RecordLayer& record_layer);
HandshakeLayer(RecordLayer& record_layer, RecordProtection& outbound, RecordProtection& inbound);
std::expected<void, HandshakeLayerError> send_message(std::span<const std::byte> message);
...
private:
RecordLayer* record_layer_;
RecordProtection* outbound_ = nullptr;
RecordProtection* inbound_ = nullptr;
std::vector<std::byte> buffer_;
};// tcp-tls13/src/handshake_layer.cpp
std::expected<void, HandshakeLayerError>
HandshakeLayer::fill_buffer(std::size_t at_least)
{
while (buffer_.size() < at_least)
{
auto record = record_layer_->receive_record();
if (!record)
{
return std::unexpected(map_record_error(record.error()));
}
if (record->type == ContentType::ChangeCipherSpec)
{
continue;
}
if (outbound_ != nullptr)
{
if (record->type != ContentType::ApplicationData)
{
return std::unexpected(HandshakeLayerError::UnexpectedContentType);
}
auto opened = inbound_->open(record->fragment);
if (!opened || opened->type != ContentType::Handshake)
{
return std::unexpected(HandshakeLayerError::ProtocolError);
}
buffer_.insert(buffer_.end(), opened->plaintext.begin(), opened->plaintext.end());
continue;
}
if (record->type != ContentType::Handshake)
{
return std::unexpected(HandshakeLayerError::UnexpectedContentType);
}
buffer_.insert(buffer_.end(), record->fragment.begin(), record->fragment.end());
}
return {};
}送信側send_message()も同様に、outbound_がセットされていればRecordProtection::seal()で暗号化した結果をContentType::ApplicationDataのレコードとして送ります。復号後の平文をバッファに積んでからハンドシェイクメッセージの境界を再構成する部分——receive_message()側——は暗号化の有無にかかわらず変わりません。1つの暗号化レコードが複数のハンドシェイクメッセージを含むこともあれば、1つのハンドシェイクメッセージが複数の暗号化レコードにまたがることもあり、いずれもバイト列レベルで吸収されます。
ChangeCipherSpec——ミドルボックス互換のための空レコード
上のコードにContentType::ChangeCipherSpecを読み捨てる分岐があります。これはTLS 1.3が本来必要としない、歴史的経緯によるレコードです。
RFC 8446 §5は次のように定めています。
An implementation may receive an unencrypted record of type change_cipher_spec consisting of the single byte value 0x01 at any time after the first ClientHello message has been sent or received and before the peer's Finished message has been received and MUST simply drop it without further processing.
TLS 1.2以前ではChangeCipherSpecは「ここから暗号化を開始する」という実際の意味を持つ制御レコードでしたが、TLS 1.3では鍵の切り替えはメッセージの並び自体で決まるため不要になりました。それでも多くの実装(このプロジェクトの実機確認に使ったopenssl s_serverを含む)は、TLS 1.3を認識しない古いミドルボックスがハンドシェイクを妨害しないよう、ServerHelloの直後に中身0x01のChangeCipherSpecレコードを平文のまま送ります(詳細な経緯はAppendix D.4 "Middlebox Compatibility Mode")。
このレコードは通常のTLSレコードヘッダを持つためRecordLayerは問題なく読み取れますが、typeがHandshakeでもApplicationDataでもないため、HandshakeLayerが特別扱いしなければ復号処理に渡そうとしてエラーになります。RFC本文が「復号を試みる前にこの状態を検出する必要がある」と明記している通り、fill_bufferではレコードのcontent typeを見た直後、RecordProtection::open()を呼ぶより前にこの分岐を置いています。
RFC 8448による検証
RFC 8448 §3のトレースはChangeCipherSpecレコードを含みませんが、AEAD暗号処理そのものを検証するのに十分な実例を提供しています。server_handshake_traffic_secretから導出される鍵/IVと、サーバの最初の暗号化ハンドシェイクレコードの実際の暗号文(679オクテット)が掲載されているので、鍵導出から復号までを一気通貫でテストできます。
// tcp-tls13/tests/record_protection_test.cpp
TEST_CASE("RecordProtection::open: RFC 8448 server's first encrypted handshake record")
{
auto secret = from_hex_array<32>(ServerHsTrafficSecretHex);
auto keys = tcptls13::derive_traffic_keys(secret);
REQUIRE(keys.has_value());
auto record = from_hex(ServerEncryptedRecordHex);
auto ciphertext = std::span(record).subspan(5); // 先頭5byteは外側レコードヘッダ
tcptls13::RecordProtection protection(*keys);
auto opened = protection.open(ciphertext);
REQUIRE(opened.has_value());
CHECK(opened->type == tcptls13::ContentType::Handshake);
CHECK(opened->plaintext.size() == 657); // EncryptedExtensions+Certificate+CertificateVerify+Finished
}seal()側も、TLS 1.3のFinished検証と送信で扱うクライアントのFinishedメッセージを同じ鍵で暗号化した結果がRFC 8448の実例と1バイトも違わず一致することを確認しています。GCMは決定的な暗号なので、同じ鍵・nonce・平文・AADからは常に同じ暗号文が得られます。
参考リンク
- RFC 8446 §5.2: Record Payload Protection —— TLSInnerPlaintext/TLSCiphertextの構造とAdditional Dataの定義
- RFC 8446 §5.3: Per-Record Nonce —— シーケンス番号とIVからのnonce構成
- RFC 8446 §5.4: Record Padding —— 末尾からcontent typeを走査するアルゴリズム
- RFC 8446 §5 および Appendix D.4: Middlebox Compatibility Mode —— ChangeCipherSpecレコードの経緯
- RFC 8448: Example Handshake Traces for TLS 1.3 §3 —— AEAD鍵/暗号文の実例トレース
- Handshake Traffic Secretの導出はTLS 1.3のHandshake Traffic Secret導出で扱っています
- 暗号化前のTLSレコード層(TLSPlaintextのフレーミング)はTLS 1.3のレコード層実装で扱っています