TLS 1.3 将密钥协商所需材料提前放入 ClientHello,使常见完整握手可以减少一轮等待;同时把握手流量与应用流量分阶段保护。它仍需验证身份、协商历史和密钥,0-RTT 也有不同于普通应用数据的安全属性。
截至 2026 年 9 月,TLS 1.3 更新规范为 2026 年 7 月发布的 RFC 9846,取代 RFC 8446;本篇以更新规范为入口,并保留旧资料常见名称的对应关系。ECH 已于 2026 年 3 月发布为 RFC 9849,不再只是一份草案。RFC 9846、RFC 9849
| 问题 | 核心理解 |
| 为什么常见握手只需一 RTT | 客户端预先提供合适 key_share |
| 为何会出现 HelloRetryRequest | 共享材料、cookie 等条件需要补充 |
| 证书加密前用什么密钥 | ServerHello 后先派生握手密钥 |
| TLS 1.3 是否必然前向保密 | 要区分非对称协商、PSK-only 和 early data |
| HKDF 三层为何不能直接 Extract 串起来 | 层间有 derived、各分支有标签和 transcript |
| ECH 是否隐藏全部访问行为 | 隐藏内层 ClientHello,元数据仍有边界 |
版本、套件和认证算法拆开协商
TLS 1.3 的版本选择看 supported_versions;兼容字段 legacy_version 使用 0x0303,因此看到这个字段不能直接判定最终协商 TLS 1.2。ServerHello 的 selected version 与完整握手上下文才是判断依据。
TLS_AES_128_GCM_SHA256 只表达记录 AEAD 与 HKDF/transcript 所用 hash,不像 TLS 1.2 套件名还绑定密钥交换和认证类别。key_share/supported_groups 与 signature_algorithms 分别表达其他约束,不能从 AES 套件名推断证书必须 RSA 或密钥交换必须 X25519。
TLS 1.3 移除了静态 RSA key exchange、CBC 记录保护等旧机制;RSA 签名并未因此全部消失。规范中的基础五套件也不意味着注册空间永远只可能有五个值。TLS 1.2 与 TLS 1.3 套件不可交叉使用。RFC 9846:协商与套件
一次往返中的四个关键动作
客户端在 ClientHello 提供支持的组和所选组的公开 key share;服务端选择兼容材料,返回自身 share。常见 ECDHE 模式下,双方此时能计算共享秘密,派生握手流量密钥,于是后续 Certificate 等消息可以加密。
“已经能解密”不代表“已经认证对方”。客户端仍要验证证书服务身份、CertificateVerify 签名与 Finished;服务端收到客户端 Finished 后确认相应握手状态。在普通服务器认证场景,客户端第一 RTT 后可以发送 Finished 和随后应用数据,但二者使用不同阶段密钥。
这里的一 RTT 是 TLS 握手语义,不包括 TCP 建连、DNS 或业务响应。TLS 1.2 也并非所有握手消息全明文,它的 Finished 使用协商后的记录保护;TLS 1.3 的优势是更早保护更多握手内容,而非从“完全无保护”一步跳来。
HelloRetryRequest:需要材料时正常重试
supported_groups 列出支持的组,key_share 只携带其中某些组的实际材料;服务端需要未携带但双方支持的组时可发送 HRR,也可能要求 cookie。客户端按限制构造第二份 ClientHello,多出一次往返。
HRR 是具有特殊 Random 值的 ServerHello 结构,不能仅看消息类型就当普通 ServerHello。transcript 使用合成的 message_hash 纳入第一份 ClientHello 的哈希,不能简单丢弃它。实现还需检查不允许的第二次 HRR及非法参数变化。RFC 9846:HelloRetryRequest
是否发生 HRR 取决于部署组策略和客户端材料,不能用“X25519 几乎总猜中”作为永久事实。更新规范用更一般的非对称密钥建立表述相关机制,不能把全部未来 key_share 都定义成椭圆曲线上的裸公钥。
CertificateVerify 与 Finished 各证明什么
CertificateVerify 使用对应证书私钥对规定的上下文和 transcript hash 签名,把身份认证绑定到本次握手。该签名输入还含固定填充和角色上下文字符串,不是随意直接签一段 ClientHello 文本。
Finished 则从该方向握手流量秘密派生 finished_key,以 HMAC 认证 transcript hash。SHA256 套件下 verify_data 为 32 B,SHA384 为 48 B,不能照搬 TLS 1.2 常见 12 B。证书验证、私钥持有证明与共享密钥确认互补,缺一项都不能仅靠“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 前缀的标签和 context。标签是协议字节,不能因自然语言变量改名而改变。RFC 5869:HKDF
记 Derive-Secret(S,label,messages) 为使用 transcript hash 的标签派生。常见非对称协商路径为:
Early = Extract(zeros, PSK),未使用 PSK 时输入按 hash 长度填零。
derived₁ = Derive-Secret(Early, "derived", empty_messages)。
Handshake = Extract(derived₁, shared_secret)。
derived₂ = Derive-Secret(Handshake, "derived", empty_messages)。
Main = Extract(derived₂, zeros)。
两次 Extract 之前都需要先派生 derived secret;省略任意一次,后续秘密与流量密钥都会改变。Main Secret 在 RFC 8446 资料中称 Master Secret;更新规范改变术语,但保留兼容所需标签。RFC 9846:密钥计划
握手 traffic secret 绑定至 ServerHello 的 transcript,初始应用 traffic secret 绑定至服务器 Finished,恢复相关秘密还绑定至客户端 Finished。并非每个 Expand 都混入“此刻所有消息”,key/IV/finished 等分支有各自 context 规则,不能统一替换。
记录 nonce 与开销
应用 traffic secret 派生 key 和 IV;TLS 1.3 记录 nonce 由该方向 IV 与补齐的记录序号异或得到,不像 TLS 1.2 GCM 那样在线上另发 8 B 显式 nonce。密钥更新后按新状态管理序号,不能让同一 key/nonce 被重复使用。
无额外 padding 的 AES-GCM 例子:1000 B 内容加 1 B 内层 content type、16 B tag、5 B 外层头,总计 1022 B TLS 数据。内层真实类型加密,外层受保护记录使用相应兼容类型;这不隐藏包长和时序。
同一个秘密,为什么能派生不同方向的密钥?
方向切换应只改变traffic及其子分支,不改变上游Handshake Secret;修改transcript也是如此。勾选省略derived则改变Extract的salt,影响从Handshake开始的全部后续值。通过变化边界来读依赖图。
正在呈现知识画面
同一个秘密,为什么能派生不同方向的密钥?标签和context是派生输入,不是说明文字。c/s分支共享父秘密却分离用途;省略derived会在更早的Extract步骤产生不同结果。
{"id":"tls13-hkdf-labels","title":"同一个秘密,为什么能派生不同方向的密钥?","summary":"标签和context是派生输入,不是说明文字。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":"改变标签或transcript,查看HKDF编码与实际输出;也可对照省略derived的错误路径。","message":"transcript 教学字节","outputs":"派生结果前8 B","ready":"已重算","reset":"重置","role":"方向标签","scope":"实际SHA256/HMAC HKDF,无PSK,shared=00…1f,transcript为教学字符串。只计算到握手traffic secret与16 B 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-only(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 是不同机制。应用若需要刷新授权状态,应在自己的生命周期中设计,不能把“换了流量 key”当作用户权限已重新验证。
ECH:保护内层 ClientHello,不消灭所有元数据
RFC 9849 定义内外 ClientHello:真实敏感参数放入被保护的内层,外层提供供前端处理的公开信息;持有相应私钥的服务端解密内层。它保护的不只是裸 SNI 字段,也需要处理接受确认、配置过期和失败重试。RFC 9849:ECH
客户端需要先取得 ECHConfigList。DNS SVCB/HTTPS 的 ech 参数是一种标准引导方式,配置获取的真实性与隐私也属于完整部署的一部分;并非只要域名存在 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 原语和部分派生关系;没有实现完整 transcript、证书认证、真实 ECDHE/ECH 或 TLS 互操作。公开测试值不是生产秘密。RFC 8448:握手测试材料
继续查阅