はじめに
https://yuhi-sa.github.io/posts/20260614_cryptography_roadmap/1/ では「TLS 1.3 ハンドシェイク詳解」を将来追加予定のプレースホルダとして挙げていました。本記事ではこれを実装します。TLS 1.3は、https://yuhi-sa.github.io/posts/20260702_elliptic_curve_cryptography/1/ で解説したECDH鍵交換、https://yuhi-sa.github.io/posts/20260703_aes_symmetric_crypto/1/ で解説したAES-GCM、https://yuhi-sa.github.io/posts/20260704_digital_signature/1/ で解説したECDSA/EdDSA署名という既存記事群の部品を組み合わせたプロトコルです。本記事では、これらの部品がどう組み合わさって安全なセッション鍵が生まれるのかを、HKDF鍵導出関数の導出とPython実装を通じて具体的に追います。
TLS 1.3ハンドシェイクの全体像(1-RTT)
TLS 1.2以前は鍵交換の合意に2往復(2-RTT)を要しましたが、TLS 1.3は**1往復(1-RTT)**に短縮されました。クライアントが最初のメッセージで鍵交換用の公開鍵候補を送ってしまう「楽観的」な設計によるものです。
| ステップ | 送信者 | 内容 |
|---|---|---|
| 1 | Client → Server | ClientHello:対応する暗号スイート・ECDHE公開鍵(key_share)・サポートするグループ(X25519等)を送信 |
| 2 | Server → Client | ServerHello:ECDHE公開鍵を返答。この時点で両者はECDHEの共有鍵を計算可能 |
| 3 | Server → Client | {EncryptedExtensions, Certificate, CertificateVerify, Finished}:ここから先はハンドシェイクトラフィック鍵で暗号化される |
| 4 | Client → Server | Finished:クライアント側のハンドシェイク完了通知 |
| 5 | 両者 | アプリケーショントラフィック鍵に切り替えて通信開始 |
ステップ2の時点で鍵交換が完結するため、クライアントは早くもステップ3を待たずに暗号化されたアプリケーションデータの送信準備に入れます(実際のTLS 1.3では0-RTTという再開機能もありますが、本記事では基本の1-RTTフローに絞ります)。
ECDHE鍵交換:X25519
TLS 1.3が必須とする鍵交換はECDHE(Ephemeral Elliptic-curve Diffie-Hellman)で、多くの実装がCurve25519(X25519)を使います。https://yuhi-sa.github.io/posts/20260702_elliptic_curve_cryptography/1/ で解説した通り、クライアントとサーバーはそれぞれ一時鍵ペアを生成し、相手の公開鍵と自分の秘密鍵からスカラー倍算により同一の共有鍵を導出します。
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
client_priv = X25519PrivateKey.generate()
server_priv = X25519PrivateKey.generate()
shared_secret_client = client_priv.exchange(server_priv.public_key())
shared_secret_server = server_priv.exchange(client_priv.public_key())
assert shared_secret_client == shared_secret_server
実行結果:
client-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
server-computed shared secret: 40b650f3f437f715c5843e0c3660e9acdbbbe1855350ab5a3c87bd22344d402c
match: True
両者が独立に計算した共有鍵が完全に一致しました。しかし、この共有鍵をそのまま暗号鍵として使うわけではありません。TLS 1.3はHKDFという鍵導出関数のチェーンを通して、用途別の複数の鍵を安全に導出します。
HKDF:Extract-and-Expand鍵導出関数(RFC 5869)
HKDFは2段階で構成されます。
HKDF-Extract:入力鍵材料(IKM)から、統計的に一様な疑似ランダム鍵(PRK)を抽出します。
\[ \text{PRK} = \text{HMAC-Hash}(\text{salt}, \text{IKM}) \tag{1} \]HKDF-Expand:PRKと文脈情報(info)から、必要な長さの出力鍵材料(OKM)を生成します。
\[ T(0) = \varnothing, \qquad T(i) = \text{HMAC-Hash}(\text{PRK},\ T(i-1) \Vert \text{info} \Vert i) \tag{2} \] \[ \text{OKM} = T(1) \Vert T(2) \Vert \cdots \quad \text{(先頭 L バイトを切り出す)} \tag{3} \]Python実装:
import hmac, hashlib
def hkdf_extract(salt, ikm, hash_len=32):
if not salt:
salt = b"\x00" * hash_len
return hmac.new(salt, ikm, hashlib.sha256).digest()
def hkdf_expand(prk, info, length, hash_len=32):
n = -(-length // hash_len) # 切り上げ除算
t = b""
okm = b""
for i in range(1, n + 1):
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
return okm[:length]
数値検証:RFC 5869テストベクタとの一致
RFC 5869 Test Case 1(salt/IKM/info固定、出力長42バイト)を使い、上記のフルスクラッチ実装と cryptography.hazmat.primitives.kdf.hkdf.HKDF の出力を比較しました。
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
salt = bytes.fromhex("000102030405060708090a0b0c")
ikm = bytes([0x0b] * 22)
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
prk_scratch = hkdf_extract(salt, ikm)
okm_scratch = hkdf_expand(prk_scratch, info, 42)
hkdf_lib = HKDF(algorithm=hashes.SHA256(), length=42, salt=salt, info=info)
okm_lib = hkdf_lib.derive(ikm)
OKM (scratch) : 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
OKM (cryptography): 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865
match: True
フルスクラッチ実装と標準ライブラリの出力が完全一致しました。この検証済みのHKDFを基盤として、TLS 1.3独自の鍵スケジュールを構築します。
TLS 1.3の鍵スケジュール(RFC 8446 §7.1)
TLS 1.3は、HKDFに「ラベル」を付与した HKDF-Expand-Label という薄いラッパーを定義し、これを連鎖的に適用して段階的に鍵を派生させます。
HkdfLabel は長さ・ラベル文字列("tls13 " + label)・文脈(通常はメッセージのトランスクリプトハッシュ)を構造化してまとめたバイト列です。鍵スケジュールは次のように進みます。
0
|
v
PSK -> HKDF-Extract = Early Secret
|
+-----> Derive-Secret(., "derived", "")
|
v
(EC)DHE -> HKDF-Extract = Handshake Secret
|
+-----> Derive-Secret(., "c hs traffic", transcript) = client_handshake_traffic_secret
+-----> Derive-Secret(., "s hs traffic", transcript) = server_handshake_traffic_secret
PSK(事前共有鍵)を使わない通常のフルハンドシェイクでは、Early Secretの入力IKMはゼロ埋めのバイト列です。
import struct
def hkdf_expand_label(secret, label, context, length):
full_label = b"tls13 " + label
hkdf_label = (struct.pack(">H", length) + bytes([len(full_label)]) + full_label
+ bytes([len(context)]) + context)
return hkdf_expand(secret, hkdf_label, length)
def derive_secret(secret, label, messages_hash):
return hkdf_expand_label(secret, label, messages_hash, 32)
zero_key = b"\x00" * 32
empty_hash = hashlib.sha256(b"").digest()
early_secret = hkdf_extract(b"\x00", zero_key)
derived_early = derive_secret(early_secret, b"derived", empty_hash)
handshake_secret = hkdf_extract(derived_early, shared_secret_client)
transcript_hash = hashlib.sha256(b"ClientHello||ServerHello (mock transcript)").digest()
client_hs_traffic_secret = derive_secret(handshake_secret, b"c hs traffic", transcript_hash)
server_hs_traffic_secret = derive_secret(handshake_secret, b"s hs traffic", transcript_hash)
client_write_key = hkdf_expand_label(client_hs_traffic_secret, b"key", b"", 16) # AES-128-GCM鍵
client_write_iv = hkdf_expand_label(client_hs_traffic_secret, b"iv", b"", 12)
実行結果:
handshake_secret : a2b298340a3f2cca87265e560a30a34d83101fc756b17d17dc249de535c2020e
client_hs_traffic_secret : 0430d76b951a157b85dd83362dc3c4fca59f8b861de863bd20a78365a898e702
server_hs_traffic_secret : 3056eba25fdda8d7a9630e4f2de5d6f37c48adb19301a4c7ccd6966398cba60b
client_write_key (16B) : 6091cfaa9bb4cedc772cef8aea5e5a93
client_write_iv (12B) : 025b4bcfb3454a951f3c9ab9
X25519の共有鍵1つから、HKDFの連鎖適用によって用途の異なる複数の鍵(クライアント/サーバーそれぞれのハンドシェイクトラフィック鍵、そこからさらに暗号鍵とIV)が導出されました。トランスクリプトハッシュを鍵導出の入力に含める設計により、通信内容が改ざんされるとそれ以降のすべての鍵が変わってしまうため、ハンドシェイク全体の完全性が暗号学的に保証されます。
AEADによるレコード保護:導出鍵での暗号化・復号
導出した client_write_key / client_write_iv を使って、実際にAES-128-GCMでハンドシェイクレコードを暗号化・復号できることを確認しました。
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aead = AESGCM(client_write_key)
plaintext = b"Finished message (mock TLS 1.3 handshake record)"
aad = b"\x17\x03\x03\x00\x50" # TLSCiphertextレコードヘッダを模したAAD
ciphertext = aead.encrypt(client_write_iv, plaintext, aad)
recovered = aead.decrypt(client_write_iv, ciphertext, aad)
assert recovered == plaintext
decrypted matches : True
ECDHE鍵交換からHKDF鍵スケジュール、AEAD暗号化までのエンドツーエンドの流れが実際に動作することを確認できました。https://yuhi-sa.github.io/posts/20260703_aes_symmetric_crypto/1/ で解説したAEADのnonce一意性の要件は、client_write_iv がレコード番号と組み合わされることで満たされます(本記事では簡略化のため単一レコードのみ扱っています)。
TLS 1.2からの主な変更点
| 項目 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| ハンドシェイク往復数 | 2-RTT | 1-RTT(再開時は0-RTT) |
| 鍵交換 | RSA鍵交換 or (EC)DHE | (EC)DHE必須(前方秘匿性を強制) |
| 対称暗号 | CBCモード等(パディングオラクル攻撃のリスク) | AEAD必須(AES-GCM / ChaCha20-Poly1305) |
| 鍵導出 | PRF(TLS固有) | HKDF(RFC 5869準拠) |
| 危殆化した機能 | RC4・SHA-1・静的RSA鍵交換 | 削除済み |
TLS 1.2で認められていたRSA鍵交換(サーバーの秘密鍵が漏洩すると過去の全通信が復号できる)が廃止され、(EC)DHEによる前方秘匿性が必須化された点が最大の設計変更です。https://yuhi-sa.github.io/posts/20260614_diffie_hellman/1/ で解説した「セッションごとに一時鍵を使い捨てる」設計が、TLS 1.3では選択肢ではなく必須要件になっています。
実務での確認方法
# 実際のTLS 1.3ハンドシェイクを観察する
openssl s_client -connect example.com:443 -tls1_3 -msg
# ネゴシエートされた暗号スイートを確認
openssl s_client -connect example.com:443 -tls1_3 2>/dev/null | grep "Cipher is"
Wiresharkで復号したパケットを見る場合は、SSLKEYLOGFILE 環境変数を設定してブラウザやOpenSSLクライアントに鍵ログを出力させることで、本記事で導出したものと同種の鍵(CLIENT_HANDSHAKE_TRAFFIC_SECRET 等)を実際の通信から確認できます。
関連記事
- 暗号系ロードマップ:古典暗号・対称鍵・RSA・Diffie-Hellman・楕円曲線・ハッシュ・署名・TLSのPython実装ハブ - 本記事はこのハブの「将来追加予定」プレースホルダの1つを実装したものです。
- 楕円曲線暗号(ECC)の数学と Python 実装 - 本記事のECDHE鍵交換(X25519)の理論的基盤です。
- SHA-256とHMACの理論とPython実装 - 本記事のHKDF内部で繰り返し使われるHMAC-SHA256の構成原理・長さ拡張攻撃への耐性を詳解しています。
- 耐量子暗号入門:Learning With Errors(LWE)とRegev暗号をPythonで実装 - 本記事のECDHEハンドシェイクが将来的にKyberベースの鍵交換へ移行しうる文脈を提供しています。
- AES / ChaCha20 対称鍵暗号の理論と Python 実装 - 本記事で導出した鍵を使うAEAD暗号化(AES-GCM)の詳細を解説しています。
- デジタル署名(ECDSA / EdDSA / RSA-PSS)の理論と Python 実装 - ハンドシェイクのCertificateVerifyステップで使われる署名方式です。
- Diffie-Hellman鍵交換の理論と実装 - ECDHEの土台となる(EC以前の)鍵交換の基礎理論・前方秘匿性の概念を解説しています。
- OAuth 2.0 / OpenID Connectの理論と実装 - TLSの上に構築される認可プロトコル。JWTの署名検証という別の文脈で公開鍵暗号を使う点で本記事とつながります。
参考文献
- Rescorla, E. (2018). The Transport Layer Security (TLS) Protocol Version 1.3. RFC 8446.
- Krawczyk, H., & Eronen, P. (2010). HMAC-based Extract-and-Expand Key Derivation Function (HKDF). RFC 5869.
- Thomson, M., & Turner, S. (2018). Illustrated TLS 1.3 Connection (tls13.xargs.org).
- Rescorla, E., et al. (2018). Example Handshake Traces for TLS 1.3. RFC 8448(テストベクタ).