TLS 1.3ハンドシェイク解剖:ECDHE・HKDF鍵スケジュール・AEADをPythonで実装して理解する

TLS 1.3の1-RTTハンドシェイクをRFC 8446に基づいて解剖し、X25519によるECDHE鍵交換、RFC 5869のHKDF-Extract/Expandをcryptography.hazmat.primitives.kdf.hkdf.HKDFと数値一致するようフルスクラッチ実装、HKDF-Expand-Labelによる鍵スケジュール(Early Secret→Handshake Secret)を導出し、AESGCMで実際にレコードを暗号化・復号するところまでPythonで実行検証します。

はじめに

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)**に短縮されました。クライアントが最初のメッセージで鍵交換用の公開鍵候補を送ってしまう「楽観的」な設計によるものです。

ステップ送信者内容
1Client → ServerClientHello:対応する暗号スイート・ECDHE公開鍵(key_share)・サポートするグループ(X25519等)を送信
2Server → ClientServerHello:ECDHE公開鍵を返答。この時点で両者はECDHEの共有鍵を計算可能
3Server → Client{EncryptedExtensions, Certificate, CertificateVerify, Finished}:ここから先はハンドシェイクトラフィック鍵で暗号化される
4Client → ServerFinished:クライアント側のハンドシェイク完了通知
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 という薄いラッパーを定義し、これを連鎖的に適用して段階的に鍵を派生させます。

\[ \text{HKDF-Expand-Label}(\text{secret}, \text{label}, \text{context}, L) = \text{HKDF-Expand}(\text{secret},\ \text{HkdfLabel}, L) \tag{4} \]

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.2TLS 1.3
ハンドシェイク往復数2-RTT1-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 等)を実際の通信から確認できます。

関連記事

参考文献

  • 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(テストベクタ).