TLS 1.3 は、鍵合意に必要な材料を ClientHello に事前に配置することで、一般的な完全ハンドシェイクのラウンドトリップを1回削減します。同時に、ハンドシェイクトラフィックとアプリケーショントラフィックを段階的に保護します。依然として、身份の検証、履歴の合意、鍵の合意が必要であり、0-RTT にも通常アプリケーションデータとは異なるセキュリティ特性があります。
2026年9月現在、TLS 1.3 の更新仕様は2026年7月に公開された RFC 9846 で、RFC 8446 を置き換えています。本稿では更新仕様を入口とし、旧資料の一般的な名称との対応関係も残しています。ECH は2026年3月に RFC 9849 として公開され、もはや草案ではありません。RFC 9846 、RFC 9849
問題 核心的な理解
なぜ一般的なハンドシェイクは1 RTT で済むのか クライアントが適切な key_share を事前に提供するため
なぜ HelloRetryRequest が発生するのか 共有材料、cookie などの条件を補完する必要があるため
証明書暗号化前にどの鍵を使うのか ServerHello 後にまずハンドシェイク鍵を派生する
TLS 1.3 は必ず前方秘匿性を持つか 非対称合意、PSK のみの場合、early data を区別する必要がある
HKDF の3層を Extract だけで直列にできないのはなぜか 層間に derived があり、各ブランチにはタグとトランスクリプトがあるため
ECH はすべてのアクセス動作を隠蔽するか 内側の ClientHello を隠すが、メタデータには境界が残る
バージョン、スイート、認証アルゴリズムの分離合意
TLS 1.3 のバージョン選択は supported_versions を見ます。互換性フィールドの legacy_version は 0x0303 を使用するため、このフィールドを見ただけでは最終的に TLS 1.2 に合意したと直接判断できません。ServerHello の selected version と完全ハンドシェイクのコンテキストが判断の根拠となります。
TLS_AES_128_GCM_SHA256 は、記録層の AEAD と HKDF/トランスクリプトに使用されるハッシュのみを示し、TLS 1.2 のスイート名のように鍵交換や認証カテゴリを束縛しません。key_share/supported_groups と signature_algorithms が他の制約を示しており、AES スイート名から証明書が RSA でなければならない、または鍵交換が X25519 でなければならないと推論できません。
TLS 1.3 は、静的 RSA 鍵交換、CBC 記録保護などの旧メカニズムを削除しました。これにより RSA 署名がすべて消えたわけではありません。仕様上の基本5スイートが、登録空間に永遠に5つの値しか存在し得ないことを意味するわけではありません。TLS 1.2 と TLS 1.3 のスイートは相互に使用できません。RFC 9846:合意とスイート
1ラウンドトリップ内の4つの重要なアクション
証明書認証 + ECDHE の一般的な完全ハンドシェイク:まず秘密を合意し、次に履歴を検証
クライアント
サーバー
ClientHello:supported_versions、key_share など
ServerHello:選択されたバージョン、スイート、key_share
暗号化:EncryptedExtensions、Certificate、CertificateVerify、Finished
暗号化:Finished;その後、アプリケーション鍵を使用してデータを送信
ClientHello/ServerHello のベースラインは依然として可視です。Finished と Application Data は異なる段階の traffic keys を使用します。
クライアントは ClientHello でサポートされるグループと選択されたグループの公開 key_share を提供します。サーバーは互換性のある材料を選択し、自身の share を返します。一般的な ECDHE モードでは、この時点で双方が共有秘密を計算でき、ハンドシェイクトラフィック鍵を派生できるため、その後の Certificate などのメッセージを暗号化できます。
「すでに復号できる」からといって「相手を認証した」わけではありません。クライアントは依然として証明書によるサービス身元の検証、CertificateVerify の署名、Finished の検証を行う必要があります。サーバーはクライアントの Finished を受信して、対応するハンドシェイク状態を確認します。通常のサーバー認証シナリオでは、クライアントは最初の RTT 後に Finished とその後のアプリケーションデータを送信できますが、これらは異なる段階の鍵を使用します。
ここで言う1 RTT は TLS ハンドシェイクのセマンティクスであり、TCP 接続確立、DNS、またはビジネス応答は含まれません。TLS 1.2 でもすべてのハンドシェイクメッセージが平文であるわけではありません。TLS 1.2 の Finished は合意後の記録保護を使用します。TLS 1.3 の利点は、より早くより多くのハンドシェイクコンテンツを保護することであり、「完全に保護なし」から一歩で跳躍するものではありません。
HelloRetryRequest:材料が必要な場合の通常の再試行
supported_groups はサポートされるグループをリストし、key_share はその中の一部のグループの実際の材料のみを保持します。サーバーが保持されていないが双方がサポートするグループを必要とする場合、HRR を送信できるほか、cookie の要求も行う可能性があります。クライアントは制限に従って2番目の ClientHello を構築し、1ラウンドトリップ余計にかかります。
HRR は特殊な Random 値を持つ ServerHello 構造であり、メッセージタイプだけを見て通常の ServerHello と見なすことはできません。トランスクリプトは合成された message_hash を使用して最初の ClientHello のハッシュに組み込まれ、単純に破棄することはできません。実装では、許可されない2回目の HRR および不正なパラメータ変更もチェックする必要があります。RFC 9846:HelloRetryRequest
HRR の発生は、デプロイされたグループポリシーとクライアントの材料に依存しており、「X25519 はほぼ常に当てはまる」ということを永続的な事実として使用することはできません。更新仕様は、より一般的な非対称鍵確立の表現を使用して関連メカニズムを記述しており、すべての将来の key_share を楕円曲線上の裸の公開鍵として定義することはできません。
CertificateVerify と Finished はそれぞれ何を証明するか
CertificateVerify は、対応する証明書秘密鍵を使用して、規定されたコンテキストとトランスクリプトハッシュに署名し、身元認証を今回のハンドシェイクに結びつけます。この署名入力は、固定パディングと役割コンテキスト文字列も含んでおり、ClientHello テキストの断片を随意に直接署名するものではありません。
Finished は、その方向のハンドシェイクトラフィック秘密から finished_key を派生し、HMAC でトランスクリプトハッシュを検証します。SHA256 スイートでは verify_data は 32 バイト、SHA384 では 48 バイトであり、TLS 1.2 で一般的な 12 バイトをそのままコピーすることはできません。証明書検証、秘密鍵所持の証明、共有鍵の確認は補完関係にあり、どれか1つが欠けても「TLS で復号できる」だけで補うことはできません。RFC 9846:認証メッセージ
互換モードではダミーの CCS が表示される場合がありますが、TLS 1.3 は TLS 1.2 のような CCS を使用して実際の暗号化状態を切り替えません。パケットキャプチャは、ハンドシェイクの段階と traffic secret に基づいて記録を理解すべきであり、各 CCS を鍵切り替えイベントとして扱うべきではありません。
HKDF:Extract で秘密を混合し、Expand で用途を分離
HKDF-Extract は salt と入力鍵材料から PRK を取得します。HKDF-Expand は info と長さを使用して出力を派生します。TLS の HKDF-Expand-Label は、info に長さ、tls13 プレフィックス付きのタグ、およびコンテキストをエンコードします。タグはプロトコルバイトであり、自然言語の変数名の変更によって変更されるものではありません。RFC 5869:HKDF
鍵スケジュール:2つの derived ステップを省略できない
Early Secret = Extract(0, PSK またはゼロ値)
早期トラフィック鍵はこの段階から派生
Handshake Secret = Extract(derived₁, 共有秘密)
クライアント/サーバーのハンドシェイクトラフィック秘密を派生
Main Secret = Extract(derived₂, ゼロ値)
アプリケーショントラフィック秘密、exporter、回復秘密を派生
Derive-Secret(Early Secret, "derived", 空メッセージ)
Derive-Secret(Handshake Secret, "derived", 空メッセージ)
図は一般的な非対称共有秘密を混合するパスを示しています。traffic secret は、タグ付きの Expand を使用して各方向の key、IV などを派生します。
Derive-Secret(S,label,messages) をトランスクリプトハッシュのタグを使用して派生するものとして覚えます。一般的な非対称合意パスは以下の通りです:
Early = Extract(zeros, PSK)。PSK を使用しない場合、入力はハッシュ長でゼロ埋めされます。
derived₁ = Derive-Secret(Early, "derived", empty_messages)。
Handshake = Extract(derived₁, shared_secret)。
derived₂ = Derive-Secret(Handshake, "derived", empty_messages)。
Main = Extract(derived₂, zeros)。
2回の Extract の前には、必ず derived secret を派生する必要があります。どちらかを省略すると、その後の秘密とトラフィック鍵が変更されます。Main Secret は RFC 8446 の資料では Master Secret と呼ばれていましたが、更新仕様で用語が変更されました。ただし、互換性のために必要なタグは保持されています。RFC 9846:鍵スケジュール
ハンドシェイク traffic secret は ServerHello のトランスクリプトにバインドされ、初期アプリケーション traffic secret はサーバーの Finished にバインドされ、回復関連秘密はクライアントの Finished にバインドされます。すべての Expand が「此刻のすべてのメッセージ」を混合するわけではありません。key/IV/finished などのブランチにはそれぞれ context ルールがあり、統一して置き換えることはできません。
記録 nonce とオーバーヘッド
アプリケーション traffic secret は key と IV を派生します。TLS 1.3 の記録 nonce は、この方向の IV とパディングされた記録シーケンス番号の XOR で取得され、TLS 1.2 の GCM のようにオンラインで別途 8 バイトの明示的な nonce を送信する必要はありません。鍵更新後は新しい状態に従ってシーケンス番号を管理し、同じ key/nonce が再利用されないようにする必要があります。
パディングなしの AES-GCM の例:1000 バイトのコンテンツに 1 バイトの内部 content type、16 バイトの tag、5 バイトの外部ヘッダーを追加すると、合計 1022 バイトの TLS データになります。内部の実際のタイプは暗号化され、外部は保護された記録で対応する互換タイプを使用します。これにより、パケット長とタイミングは隠蔽されません。
同じ秘密なのに、なぜ異なる方向の鍵を派生できるのか?
方向の切り替えは、traffic およびそのサブブランチのみを変更し、上位の Handshake Secret は変更してはいけません。トランスクリプトを変更する場合も同様です。derived を省略すると Extract の salt が変更され、Handshake 以降のすべての後続値に影響します。変更の境界を通じて依存関係図を読んでください。
図を準備しています 再読み込み
同じ秘密なのに、なぜ異なる方向の鍵を派生できるのか? タグとコンテキストは派生入力であり、説明文ではありません。c/s ブランチは親秘密を共有しながら用途を分離します。derived を省略すると、より早い Extract ステップで異なる結果が生じます。
{"id":"tls13-hkdf-labels","title":"同じ秘密なのに、なぜ異なる方向の鍵を派生できるのか?","summary":"タグとコンテキストは派生入力であり、説明文ではありません。c/s ブランチは親秘密を共有しながら用途を分離します。derived を省略すると、より早い Extract ステップで異なる結果が生じます。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"role\"><button data-mode=\"c\" data-i18n=\"client\"></button><button data-mode=\"s\" data-i18n=\"server\"></button></div><label class=\"form-field\"><span data-i18n=\"message\"></span><input type=\"text\" id=\"message\" value=\"hello transcript\" maxlength=\"24\" autocomplete=\"off\" spellcheck=\"false\"></label><label class=\"check\"><input type=\"checkbox\" id=\"skip\"><span data-i18n=\"skip\"></span></label><section class=\"panel\"><h3 data-i18n=\"outputs\"></h3><div id=\"outputs\"></div></section><p id=\"encoding\" class=\"message\"></p><p id=\"status\" class=\"status\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#outputs .tr{grid-template-columns:1fr 1.4fr}#outputs .tr>*{white-space:pre-line}.tr>*{min-height:4em;font-family:var(--viz-font-ui)}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}const enc=s=>new TextEncoder().encode(s),cat=(...xs)=>Uint8Array.from(xs.flatMap(x=>[...x])),hex=x=>[...x].map(n=>n.toString(16).padStart(2,'0')).join('');async function sha(x){return new Uint8Array(await crypto.subtle.digest('SHA-256',x))}async function hmac(k,x){const key=await crypto.subtle.importKey('raw',k,{name:'HMAC',hash:'SHA-256'},false,['sign']);return new Uint8Array(await crypto.subtle.sign('HMAC',key,x))}async function prf(secret,label,seed,n){const s=cat(enc(label),seed);let a=s,out=new Uint8Array;while(out.length<n){a=await hmac(secret,a);out=cat(out,await hmac(secret,cat(a,s)))}return out.slice(0,n)}async function expand(secret,info,n){let prev=new Uint8Array,out=new Uint8Array;for(let i=1;out.length<n;i++){prev=await hmac(secret,cat(prev,info,[i]));out=cat(out,prev)}return out.slice(0,n)}async function label(secret,name,ctx,n){const l=enc('tls13 '+name),info=cat([n>>8,n&255,l.length],l,[ctx.length],ctx);return expand(secret,info,n)}async function derive(secret,name,messages){return label(secret,name,await sha(messages),32)}let role='c',epoch=0;async function draw(){const e=++epoch;$('#status').dataset.ready='false';set('status',t('busy'));const zero=new Uint8Array(32),early=await hmac(zero,zero),salt=$('#skip').checked?early:await derive(early,'derived',new Uint8Array),hs=await hmac(salt,Uint8Array.from({length:32},(_,i)=>i)),traffic=await derive(hs,role+' hs traffic',enc($('#message').value)),key=await label(traffic,'key',new Uint8Array,16);if(e!==epoch)return;table('outputs',['Early','Extract salt','Handshake','Traffic','Key'].map((name,i)=>[name,hex([early,salt,hs,traffic,key][i].slice(0,8)).match(/.{8}/g).join(String.fromCharCode(10))]));set('encoding','Label = tls13 '+role+' hs traffic\\nLength = 32; Context = SHA256(transcript)\\nkey: Length = 16; Context = empty');$('#outputs').dataset.hex=JSON.stringify([early,salt,hs,traffic,key].map(hex));set('status',t('ready'));$('#status').dataset.ready='true';pressed(role)}table('outputs',['Early','Extract salt','Handshake','Traffic','Key'].map(x=>[x,'00000000'+String.fromCharCode(10)+'00000000']));document.querySelectorAll('input').forEach(e=>e.oninput=draw);document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{role=b.dataset.mode;draw()});draw();","audio":false,"strings":{"busy":"計算中","client":"c hs traffic","heading":"同じ秘密なのに、なぜ異なる方向の鍵を派生できるのか?","intro":"タグまたはトランスクリプトを変更し、HKDF エンコーディングと実際の出力を確認します。または、derived を省略した誤ったパスと比較することもできます。","message":"トランスクリプト教学バイト","outputs":"派生結果の先頭 8 バイト","ready":"再計算済み","reset":"リセット","role":"方向タグ","scope":"実際の SHA256/HMAC HKDF、PSK なし、shared=00…1f、トランスクリプトは教学文字列。ハンドシェイク traffic secret と 16 バイト key までの計算のみ実行し、完全な TLS 認証は実行しない。","scopeLabel":"モデル範囲と規約","server":"s hs traffic","skip":"意図的に derived を省略(誤り)","status":"計算状態"}}
PSK 回復と 0-RTT は別の事柄
回復は既存の PSK 状態を使用して新しい接続を確立し、通常の 1-RTT を選択することも、許可された場合に early data を試すこともできます。NewSessionTicket のチケットフィールドは、サーバーが認識可能な回復識別子/カプセル化であり、PSK を平文で送信するものではありません。クライアントは回復秘密と ticket nonce などを基に対応する PSK を派生します。
ClientHello の PSK identity は候補を指定し、binder はクライアントが関連する PSK を保持していることを証明し、規定された部分の ClientHello にバインドします。これは、まだ受信されていないサーバーの新しいメッセージを検証するものではなく、early data のリプレイ防止を自動的に有効にするものでもありません。
モード 新しい非対称共有秘密 前方秘匿性の境界
証明書 + 一時非対称合意 あり アルゴリズムと秘密の破棄などの条件による
PSK + 新しい非対称合意 あり 通常の後続トラフィックは新しい秘密で保護される可能性がある
PSK のみ(psk_ke) なし 新しい鍵交換の前方秘匿性を持つと主張できない
0-RTT early data サーバーの新しい share がまだ混合されていない PSK に主に依存し、同等の前方秘匿性を持たない
したがって、「TLS 1.3 は ECDHE のみを許可し、すべてのデータに前方秘匿性を強制する」という主張は成立しません。psk_dhe_ke 名称の dhe は、更新仕様では歴史的命名であり、対応する非対称鍵確立モードをカバーしています。RFC 9846:PSK モード
early data の受信とリプレイ
サーバーは early data を拒否しつつ、ハンドシェイクを継続することができます。クライアントは「ハンドシェイクの成功」と「早期操作の受信」を区別し、アプリケーションのセマンティクスに従って後続の再送信方法を決定する必要があります。max_early_data_size は受信可能な規模を制約するものであり、ビジネスの重複排除メカニズムとして使用することはできません。
攻撃者は有効な早期データをリプレイする可能性があります。サーバーは、チケットの使用、時間窓、リプレイ防止状態、およびアプリケーション設計を組み合わせて処理する必要があります。ノード間キャッシュにより、ワンタイム使用を保証するのが困難になる場合もあります。HTTP メソッド名はセキュリティ証明ではなく、GET でも副作用がある場合は軽率に事前に実行すべきではありません。0-RTT は送信前の待機時間を削減しますが、ゼロ時間でビジネス結果を受信することを意味するものではありません。RFC 9846:0-RTT とリプレイ防止
KeyUpdate:方向鍵を変更し、身份を再認証しない
接続確立後、KeyUpdate を使用してアプリケーショントラフィック秘密を進め、その後新しい key/IV でその方向の記録を保護し、規定に従って対向先に更新応答を要求します。これは証明書を再送信するものではなく、非対称鍵交換を再実行するものでもなく、すでに漏洩した現在の秘密が自動的にすべてのセキュリティを回復するものでもありません。
無効化された TLS renegotiation、ハンドシェイク後のクライアント認証、および KeyUpdate は異なるメカニズムです。アプリケーションが認証状態を更新する必要がある場合は、独自のライフサイクル内で設計すべきであり、「トラフィック鍵が変更された」ことをユーザー権限が再検証されたものと見なしてはいけません。
ECH:内側の ClientHello を保護し、すべてのメタデータを消滅させない
RFC 9849 は内外の ClientHello を定義しています。機密性の高い実際のパラメータは保護された内側に配置され、外部にはフロントエンド処理用の公開情報が提供されます。対応する秘密鍵を持つサーバーが内側を復号します。ECH が保護するのは裸の SNI フィールドだけでなく、受信確認、設定の有効期限切れ、および失敗時の再試行の処理も必要です。RFC 9849:ECH
クライアントはまず ECHConfigList を取得する必要があります。DNS SVCB/HTTPS の ech パラメータは標準的な誘導方法の1つであり、取得された設定の真正性とプライバシーも完全なデプロイメントの一部です。ドメイン名に HTTPS レコードが存在するだけで、必ずしも ECH が有効になっているわけではありません。RFC 9848:DNS 誘導 ECH
ECH は暗号化 DNS と組み合わせて名前を直接露出するのを減らしますが、目的の IP、外部の公開名、トラフィックサイズ、時間、およびサーバーログは依然として可視です。独占 IP もサイトを示唆する可能性があるため、「ユーザーがどのウェブサイトにアクセスしたかが完全に隠蔽された」と約束することはできません。
ローカル HKDF 検証と鍵導出の比較
import hashlib
import hmac
import struct
def extract ( salt , ikm ) :
return hmac . digest ( salt , ikm , ' sha256' )
def expand ( prk , info , length ) :
previous ,result = b ' ' ,b ' '
for counter in range ( 1 , ( length + 31 ) / / 32 + 1 ) :
previous = hmac . digest ( prk , previous + info + bytes ( [ counter ] ) , ' sha256' )
result += previous
return result [ : length ]
def expand_label ( secret , label , context , length ) :
label = b ' tls13 ' + label
info = struct . pack ( ' !H' , length ) + bytes ( [ len ( label ) ] ) + label + bytes ( [ len ( context ) ] ) + context
return expand ( secret , info , length )
def derive ( secret , label , messages ) :
return expand_label ( secret , label , hashlib . sha256 ( messages ) . digest ( ) , 32 )
ikm = bytes ( [ 11 ] ) * 22
prk = extract ( bytes . fromhex ( ' 000102030405060708090a0b0c' ) , ikm )
assert prk . hex ( ) == ' 077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5'
assert expand ( prk , bytes . fromhex ( ' f0f1f2f3f4f5f6f7f8f9' ) , 42 ) . hex ( ) == (
' 3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf'
' 34007208d5b887185865' )
zero = bytes ( 32 )
early = extract ( zero , zero )
assert early . hex ( ) == ' 33ad0a1c607ec03b09e6cd9893680ce210adf300aa1f2660e1b22e10f170f92a'
shared = bytes ( range ( 32 ) ) handshake = extract ( derive ( early , b ' derived' , b ' ' ) , shared )
assert handshake != extract ( early , shared )
main = extract ( derive ( handshake , b ' derived' , b ' ' ) , zero )
client = derive ( handshake , b ' c hs traffic' , b ' hello transcript' )
server = derive ( handshake , b ' s hs traffic' , b ' hello transcript' )
assert client != server
assert len ( expand_label ( client , b ' key' , b ' ' , 16 ) ) == 16
assert len ( expand_label ( client , b ' iv' , b ' ' , 12 ) ) == 12
assert len ( main ) == 32 and 1000 + 1 + 16 + 5 == 1022
print ( ' HKDF 標準ベクトル、early secret、derived ステップ、記録オーバーヘッドのチェック通過' )
この例は HKDF プリミティブと一部の派生関係を検証します。完全なトランスクリプト、証明書認証、実際の ECDHE/ECH、または TLS 相互運用性は実装されていません。公開テスト値は本番の秘密ではありません。RFC 8448:ハンドシェイクテスト材料
継続的な参照