第三章 TLS握手协议详解
握手协议是TLS的起点,也是我最常被问到的部分。说白了,握手就是客户端和服务端互相打个招呼,商量好怎么加密、用什么密钥,然后才能开始安全通信。对于咱们做水印的同学,握手阶段特别关键——水印字段能不能活着传过去,很大程度上取决于握手期间双方达成了什么默契。
我曾在线上环境碰到过一个怪问题:水印字段在测试环境一切正常,到了生产就时不时丢失。查了三天,最后发现是ClientHello里的扩展顺序被中间件调整了,导致服务器没认出水印扩展。从那以后,我对握手阶段的每个字节都特别敏感。
TLS 1.2 握手——水印字段的“裸奔”期
TLS 1.2的握手是4次消息往返(4-way handshake)。关键问题是:在握手完成之前,所有明文传输的字段都有可能被中间人观察甚至篡改。
影响水印的核心事实:ClientHello、ServerHello、Certificate等消息都是明文传输。如果你把水印放在这些消息的扩展字段里,任何能抓包的第三方都能直接看到它。这在数字版权保护场景下可能直接导致水印泄露。
来看看具体流程:
- ClientHello – 客户端告诉服务器自己支持的TLS版本、加密套件列表,以及扩展字段。水印可以塞进扩展里(比如自定义扩展或trusted_ca_keys等)。这个包完全明文。
- ServerHello – 服务器选好版本和套件,也包含自己的扩展。同样明文。
- Certificate – 服务器发证书链,证书主体里也能嵌入水印(比如OID扩展)。但这是公开信息,任何人都能解析。
- ClientKeyExchange – 客户端发送Pre-Master Secret(用公钥加密),之后双方就能计算对称密钥。从此以后所有数据都加密。
| 握手消息 | 传输方式 | 水印可见性 | 我的经验 |
|---|---|---|---|
| ClientHello | 明文 | 完全可见 | 见过有人把设备指纹塞在扩展里,直接被爬虫模拟 |
| ServerHello | 明文 | 完全可见 | 这里可以看到服务端是否支持水印扩展 |
| Certificate | 明文 | 可提取 | X.509证书的扩展域可埋水印,但会被审计日志记录 |
| ClientKeyExchange | 加密(用公钥) | 不可见 | 之后的所有数据都受保护,包括后续水印 |
注意:即使ClientKeyExchange之后的数据加密了,在水印被放入之前,握手阶段的明文消息可能已经被截取。如果你希望水印只在加密通道内传递,一定不要放在握手早期消息里。
TLS 1.3 握手——加密来得更早
TLS 1.3最大的变化是:服务器发完ServerHello后马上就生成密钥,并且从EncryptedExtensions开始所有消息都加密。这意味着水印字段的安全窗口缩小了。
流程对照:
- ClientHello(明文) + 客户端密钥猜测
- ServerHello(明文) + 服务器密钥确认
- EncryptedExtensions(已加密) – 服务器扩展字段第一次出现在密文中,水印如果放在这里,外部无法读取。
- Certificate(已加密) – 证书也是加密传输,这在1.2里可不行。
- Finished(已加密) – 双方完成验证。
所以我的建议是:在新项目中优先考虑TLS 1.3。把水印字段放在EncryptedExtensions或Certificate消息里,攻击者看不到任何明文特征。不过要注意,1.3的握手只有1-RTT(往返时间),而1.2需要2-RTT,性能更好,但水印的调度窗口也更紧凑。
影响水印字段的关键握手参数
握手过程中有若干参数直接决定了水印字段的生死:
- 扩展字段协商:双方必须都支持同一个水印扩展ID,否则水印扩展会被忽略。我曾经写过一个Patch,在Nginx里硬编码了一个自定义扩展,结果OpenSSL版本一升级就不认了。
- SSLKEYLOGFILE:调试时可以记录所有会话密钥。如果你把水印放在加密消息里,这个文件可导致它泄露。建议生产环境严格关闭。
- 会话恢复:TLS 1.3的0-RTT模式会在首次握手的Finished消息里带一些早期数据,但水印如果放早了,可能被重放攻击者看见。
- 证书链验证:如果水印嵌在证书里,中间人代理可能替换证书。你想想看,这直接让水印失效。
一张图看懂握手对水印的影响
实战避坑:水印字段与握手兼容性
我遇到过最头疼的问题是关于扩展字段的冲突。当时给一个视频流加上水印,用了TLS 1.2的custom extension。结果在微信浏览器里握手失败——因为微信内置的HTTPS代理不支持未知扩展。解决方案是改用Session Ticket中的水印,但那样又增加了延迟。
另一个坑:有些负载均衡器会在TLS握手阶段注入自己的扩展(比如ALPN、SNI),可能会占用你预留给水印的扩展ID。我建议你提前查好整个链路上的中间件文档,确定哪些扩展ID是安全的。
小技巧:在测试环境用Wireshark打开TLS握手日志,过滤 tls.handshake.extension 就能看到双方交换的所有扩展。如果发现你的水印扩展没出现,十有八九是版本协商或中间件过滤了。
写段代码演示一下——假设你想在Go的TLS中给ClientHello添加扩展:
// 在Go中自定义TLS扩展
conn, err := tls.Dial("tcp", "example.com:443", &tls.Config{
InsecureSkipVerify: true,
// 自定义扩展ID 0xFE0A,内容为水印数据
ClientSessionCache: nil,
// 通过设置支持扩展列表来包含自定义扩展
// 注意:很多标准库不开放扩展注入,需用hack
})
_ = conn
不过说实话,Go的标准库没开放自定义扩展的接口,我一般是用cgo调用openssl或者自己写个纯Go的TLS实现。嗯,这部分涉及底层细节,你们感兴趣的话可以单聊。
回到水印字段的安全性,我最后再唠叨一句:如果你把水印放在明文握手阶段,一定要加上完整性校验(比如签名),否则中间人不仅能看到,还能篡改。曾经有项目因为没做签名,被攻击者替换了证书中的水印,导致版权追踪失败。教训深刻。