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と数値一致するようフルスクラッチ実装、Early Secret→Handshake Secret→Master Secretまでの鍵スケジュール全体をPythonで導出します。0-RTTのリプレイ攻撃リスクを実際のAEAD復号で再現し、ハイブリッドPQC鍵交換(X25519MLKEM768, RFC 9954)の最新動向も解説します。

はじめに

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を待たずに暗号化されたアプリケーションデータの送信準備に入れます(再開時にはさらに往復を1つ削る0-RTTという機能もあります。そのリプレイ攻撃リスクは本記事後半の「エッジケース」節で扱います)。

TLS 1.3ハンドシェイクのシーケンス図。ClientとServerの2本のタイムラインを、平文(灰)・ハンドシェイクトラフィック鍵で暗号化(青)・アプリケーショントラフィック鍵で暗号化(緑)の3種類の矢印で結ぶ。ClientHelloの直後に任意の0-RTT early_data(黄色破線、PSK鍵のみでリプレイ可能である旨を注記)が続き、ServerHelloでHandshake Secretが確立、以後Finishedまで青、Application Data以降は緑で暗号化される。

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)が導出されました。トランスクリプトハッシュを鍵導出の入力に含める設計により、通信内容が改ざんされるとそれ以降のすべての鍵が変わってしまうため、ハンドシェイク全体の完全性が暗号学的に保証されます。

Master Secretまでの全体像:なぜ2回 Derive-Secret(., "derived", "") を挟むのか

上のコードはHandshake Secretまでで止めましたが、RFC 8446の鍵スケジュールはさらに0を入力にもう一段HKDF-Extractを適用し、Master Secretを導出します。ここから最終的にアプリケーション通信を保護する鍵と、セッション再開用の鍵が生まれます。

TLS 1.3鍵スケジュールの全体図(RFC 8446 §7.1)。0またはPSKを起点にHKDF-Extractを適用しEarly Secretを導出、0-RTT関連の鍵(binder_key等、破線・PSK使用時のみ)を分岐。Derive-Secretで一段挟んだ後、(EC)DHE共有鍵と合わせて再度HKDF-Extractして Handshake Secretを導出、クライアント/サーバーのハンドシェイクトラフィック鍵を分岐。再度Derive-Secretで一段挟み、0と合わせてHKDF-Extractして Master Secretを導出、クライアント/サーバーのアプリケーショントラフィック鍵0・exporter_master_secretと、client Finished後にresumption_master_secretを分岐。Early=黄、Handshake=青、Master=紫の3系統で色分け。

図中の Derive-Secret(., "derived", "") が2回登場する点に注目してください。これは単なる飾りではなく、前段の秘密鍵材料を「使い切って」から次のHKDF-Extractに渡すための儀式的なステップです。もしこれを省いてEarly Secretを直接次のExtractのsaltに使うと、Early SecretとHandshake Secretの間に単純な鍵導出関係が残り、片方の危殆化がもう片方に波及しやすくなります。"derived"ラベルと空文脈でワンクッション挟むことで、各段の秘密が独立した名前空間(ドメイン分離)を持つようにしているのです。

Early SecretからMaster Secretまでの導出をPythonで最後まで実行し、実際に9つすべての秘密鍵が異なる値になることを検証しました。

# 前段のhandshake_secret, client_hs_traffic_secret等は前のコードブロックからの継続
derived_hs = derive_secret(handshake_secret, b"derived", empty_hash)
master_secret = hkdf_extract(derived_hs, zero_key)

# 完全なハンドシェイクのトランスクリプトハッシュ(ClientHello..Finishedまで)
transcript_hash_full = hashlib.sha256(
    b"ClientHello||ServerHello||EE||Cert||CertVerify||Finished (mock full transcript)"
).digest()

client_app_traffic_secret_0 = derive_secret(master_secret, b"c ap traffic", transcript_hash_full)
server_app_traffic_secret_0 = derive_secret(master_secret, b"s ap traffic", transcript_hash_full)
exporter_master_secret = derive_secret(master_secret, b"exp master", transcript_hash_full)

# resumption_master_secretはクライアントのFinishedまで含めたトランスクリプトハッシュを使う
transcript_hash_client_fin = hashlib.sha256(
    b"ClientHello||ServerHello||EE||Cert||CertVerify||Finished||client Finished (mock)"
).digest()
resumption_master_secret = derive_secret(master_secret, b"res master", transcript_hash_client_fin)

実行結果:

master_secret               : 7c4e46f4163582d097b9ae23e62b2beb16004a4174f06df647c23ed90c4fc822
client_app_traffic_secret_0 : 4ba696487c18eeaef89e7838ceb044b27450a6188a536e7d66ec1df10596ef37
server_app_traffic_secret_0 : ff76ed58173eebe00c72708025470a600504a7026fec3449310178516d539009
exporter_master_secret      : cfeb72812aa3d39dd2762b2702f540e5580faebd785b4e40c976b7e1baed9a0f
resumption_master_secret    : 792f713039b2510466594beea57728390a0ffceed6f35fe032b06a0aa119aff3
all 9 derived secrets pairwise distinct : True

Early Secret・Handshake Secret・そこから枝分かれした2つのトラフィックシークレット・Master Secret・そこから枝分かれした4つの秘密鍵、合計9個すべてが相異なる値になりました。同じHMAC-SHA256というプリミティブ1つを使い回しているにもかかわらず、ラベルとコンテキストを変えるだけで暗号学的に独立な鍵の集合が得られる——これがHKDFベースの鍵スケジュール設計の核心です。

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 がレコード番号と組み合わされることで満たされます(本記事では簡略化のため単一レコードのみ扱っています)。

エッジケース:0-RTTのリプレイ攻撃リスク

TLS 1.3には、以前の接続でサーバーから受け取ったPSK(NewSessionTicketで配布される再開用鍵)を使って往復数ゼロでアプリケーションデータを送る0-RTTモードがあります。速度面のメリットは大きい一方、RFC 8446自身が「0-RTTデータはリプレイに対して脆弱である」と明記している唯一のモードでもあります。

その理由は鍵導出の構造にあります。0-RTTのclient_early_traffic_secretは、PSKとClientHelloのトランスクリプトハッシュだけから決まり、サーバー側の新しい乱数(ServerHelloのrandom)が一切混ざりません。ServerHelloを待つ通常の鍵(Handshake Secret以降)とは異なり、攻撃者がClientHello + early_dataをまるごと1パケットとして捕まえれば、中身を復号できなくても、そのバイト列をそっくりそのまま複数回サーバーに送りつけることができ、サーバーは正当な最初の送信と区別する手立てを暗号レベルでは持ちません。

Pythonで、この性質を実際に確認しました。固定のPSK(前回セッションのチケット由来と仮定した固定バイト列)から0-RTT鍵を導出し、同一の暗号文を2回「配送」してみます。

resumption_psk = hashlib.sha256(b"mock-resumption-psk-from-prior-session-ticket").digest()

early_secret_0rtt = hkdf_extract(b"\x00", resumption_psk)
ch1_transcript_hash = hashlib.sha256(b"ClientHello (mock, includes PSK identity + binder)").digest()
client_early_traffic_secret = derive_secret(early_secret_0rtt, b"c e traffic", ch1_transcript_hash)

early_write_key = hkdf_expand_label(client_early_traffic_secret, b"key", b"", 16)
early_write_iv  = hkdf_expand_label(client_early_traffic_secret, b"iv", b"", 12)

early_aead = AESGCM(early_write_key)
early_data_plaintext = b"GET /api/withdraw?amount=100 HTTP/1.1"  # 非冪等リクエストの最悪ケース
early_data_aad = b"\x17\x03\x03\x00\x26"

# クライアントはこの暗号文を1回だけ送る。攻撃者はこれをそのまま捕獲する。
captured_ciphertext = early_aead.encrypt(early_write_iv, early_data_plaintext, early_data_aad)

# 攻撃者が捕獲した暗号文をそっくりそのまま2回目として再送する
decrypted_first_delivery    = early_aead.decrypt(early_write_iv, captured_ciphertext, early_data_aad)
decrypted_replayed_delivery = early_aead.decrypt(early_write_iv, captured_ciphertext, early_data_aad)

実行結果:

client_early_traffic_secret  : 223b2d85a03b9384629dd8c5e991db215c52f7fa8e056b5098b0541bc92f0951
early_write_key (16B)        : 41383fd7fa04fa98005c313ee6d40468
early_write_iv  (12B)        : e5446faa95e19b2bf065d5c0
decrypted (1st legit delivery)   : b'GET /api/withdraw?amount=100 HTTP/1.1'
decrypted (2nd replayed delivery): b'GET /api/withdraw?amount=100 HTTP/1.1'
identical plaintext both times   : True

同一の暗号文を2回復号しても、まったく同じ平文が出力される——AEADは「この暗号文が改ざんされていないか」は保証しますが、「このメッセージを以前にも見たかどうか」は関知しないためです。もしこのリクエストが冪等でない(例えば送金API)場合、リプレイは実際の被害につながります。

RFC 8446 §8はこの前提のもとで、次のような緩和策を実装側に求めています。

対策概要トレードオフ
シングルユースチケットNewSessionTicketを1回の再開だけで使い捨てるサーバー側にチケット消費済み状態の保存が必要(分散環境では共有ストレージが必要)
クライアントHello記録直近見たClientHelloのダイジェストをキャッシュし、重複を拒否キャッシュのウィンドウ外・複数フロントエンド間では防げない
obfuscated_ticket_ageの許容窓チケット発行からの経過時間が極端に短い/長いリクエストを拒否時計のずれや正当なリトライも誤検知しうる
0-RTTを無効化・限定冪等なGETのみに0-RTTを許可し、状態変更を伴うリクエストには使わない0-RTTの速度メリットを一部放棄

これらは相互に排他的ではなく、実運用ではCDN・ロードバランサ層でのチケットキャッシュ共有と、アプリケーション層での冪等性チェックを併用するのが一般的です。0-RTTのリプレイ攻撃と対策については、Fadul, Ramadass & Gismalla (2023) がIEEE ICEESEで実装上の緩和手法を比較検討しています(詳細は参考文献参照)。

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では選択肢ではなく必須要件になっています。

最新動向:ハイブリッドPQC鍵交換への移行

本記事のECDHE鍵交換はX25519(楕円曲線)の離散対数問題の困難性に依存していますが、将来の量子コンピュータが実用化すればShorのアルゴリズムでこの困難性は崩れます。この移行は理論上の将来課題ではなく、すでに主要ブラウザとCDNで実運用が始まっている変化です。

  • NISTは2024年8月、格子ベースの鍵カプセル化機構ML-KEM(CRYSTALS-Kyberを基にした方式)をFIPS 203として正式に標準化しました(Federal Register, 2024年8月14日付)。
  • Google Chromeは2024年4月のバージョン124でドラフト版のハイブリッド鍵交換X25519Kyber768Draft00を既定で有効化し、2024年11月のバージョン131で標準化されたX25519MLKEM768に切り替えました。
  • Cloudflareのブログ記事「State of the post-quantum Internet in 2025」(Westerbaan, 2025年10月28日)によれば、Cloudflareが仲介する人間由来のHTTPSトラフィックのうち2025年10月末時点で50%超がすでにポスト量子鍵交換(ハイブリッド方式)で保護されています。

ここで重要なのは、これがTLS 1.3の鍵スケジュールを置き換えるものではなく、そこに新しい「共有鍵の作り方」を1つ追加するだけだという点です。IETFのハイブリッド鍵交換設計(RFC 9954、Chrome/Firefoxが実装するX25519MLKEM768はこの仕様に基づく)では、ECDHE鍵交換 (X25519) とPQC鍵交換 (ML-KEM-768) をそれぞれ独立に実行し、両者の共有鍵を単純に連結したバイト列を、本記事で説明したHKDF-Extractの入力(EC)DHEの位置にそのまま差し込みます。

\[ \text{IKM}_{\text{hybrid}} = \text{sharedSecret}_{\text{X25519}} \Vert \text{sharedSecret}_{\text{ML-KEM-768}} \]

つまりHandshake Secretを導出するHKDF-Extractが受け取るIKMが1本の共有鍵から2本連結した共有鍵に変わるだけで、Early Secret以降の鍵スケジュール(Derive-Secretによるドメイン分離、Master Secretへの遷移、トラフィック鍵の導出)は本記事で検証した構造がそのまま流用されます。設計思想は「ECDHEかML-KEMの片方でも安全なら、ハイブリッド全体も安全」というAND合成であり、ECDHEが将来の量子計算機に敗れても、ML-KEM側の格子問題の困難性が破られない限りセッション全体の安全性は保たれます(逆も同様)。TLS 1.3のHKDF鍵スケジュールが最初から「複数の入力を合成して1つの秘密を作る」設計だったからこそ、この移行が鍵スケジュール自体を書き換えずに済んでいる点は、本記事で見てきたHKDFの拡張性の実例と言えます。

実務での確認方法

# 実際の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(テストベクタ).
  • Fadul, M. E. A., Ramadass, S., & Gismalla, M. S. M. (2023). Replay Attack in TLS 1.3 0-RTT Handshake: Countermeasure Techniques. 2023 IEEE 6th International Conference on Electrical, Electronics and System Engineering (ICEESE). DOI: 10.1109/ICEESE56169.2023.10278190.
  • Westerbaan, B. (2025年10月28日). State of the post-quantum Internet in 2025. The Cloudflare Blog. https://blog.cloudflare.com/pq-2025/
  • NIST (2024). FIPS 203: Module-Lattice-Based Key-Encapsulation Mechanism Standard. Federal Register, 2024年8月14日付告示.
  • Stebila, D., Fluhrer, S., & Gueron, S. (2026). Hybrid Key Exchange in TLS 1.3. RFC 9954 (Informational).