前向保密限制的是长期认证密钥日后泄露造成的影响。对于经过正确认证的临时 Diffie-Hellman 交换,攻击者仅取得长期认证私钥,不能据此恢复先前的会话机密。前提是临时机密没有一起被获取;它不会让已经被攻破的端点、导出的密钥日志或保存下来的明文突然变得安全。

两类密钥承担不同职责

ECDHE 中,双方分别生成临时私钥并交换公开份额。每一方把本地私钥与对方公开份额组合,得到相同的共享机密。旁观者虽然能看到公开份额,但在算法的安全假设成立时,缺少重现计算所需的私有值。新鲜的交换材料并不是双方在网络上公开商量出的一段秘密口令。

TLS 也不会把原始 ECDH 输出直接当成应用加密密钥。密钥调度还会派生不同的握手与应用流量机密,绑定握手上下文,并区分方向和用途。临时交换只提供其中一项关键材料,不能用“双方算出相同字节”替代整个传输协议,也不能略过后续派生和认证步骤。

证书按照信任规则把身份绑定到公钥,实际签署握手材料的是对应的私钥,不是证书这个数据对象自己在签名。TLS 1.3 的证书认证路径通过 CertificateVerify 认证握手记录。如果没有身份认证,主动中间人可以替换公开份额,分别和两端建立自己的共享机密;仅仅看到加密正常,并不能排除这种攻击。

RSA 或 Ed25519 认证密钥可以与临时交换并存,签名算法和密钥交换组是不同选择。TLS 1.3 还定义了仅使用 PSK 的恢复模式,后者不具备 DHE 带来的这项前向保密;PSK 配合 DHE 则会加入新的交换材料。可选 early data 的保证更弱,因此不能单凭 TLS 版本或证书算法标签,就推断所有阶段的流量都有相同保护。

坏掉的样子

长期重复使用同一个交换私钥,会削弱原本希望建立的泄露边界。随机数不足、临时私有值暴露、崩溃转储或 TLS 密钥日志,都可能泄露前向保密假设中不能被攻击者取得的材料。日后泄露流量机密,与日后泄露证书签名私钥,不是同一个威胁场景,不能混用结论。

会话进行时攻破端点,可能直接取得明文。长期认证私钥失窃后,能够拦截未来连接的攻击者也可能冒充服务器。前向保密保护的是特定的历史录制流量场景,并不会撤销泄露凭据,也不会修复仍在持续的入侵;这些问题仍需要独立的凭据轮换与事件处置。

机密销毁更是实现层面的问题,不能仅靠一句“用完就丢弃”来保证。把 Go 变量设为 nil,不证明内存、交换空间或诊断材料中的所有副本都已消失。应避免把机密写进日志,并把凭据失窃的处置与历史流量是否仍受保护的判断分开,不能用概念上的前向保密替代真实的数据暴露调查。

快速验证

保存为 ecdhe_probe.go,使用 Go 1.23 或更新版本运行 go run ecdhe_probe.go。程序在内存中调用标准库的 X25519 与 Ed25519。签名公钥在这个实验里代表已经可信的身份;被签署的消息明确不是 TLS 握手记录。这是一项密码原语层面的实验,不是另一套 TLS 实现,也不能作为上线加密通道的配方。

package main

import (
	"bytes"
	"crypto/ecdh"
	"crypto/ed25519"
	"crypto/rand"
	"fmt"
	"runtime"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	identity, signingKey, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	curve := ecdh.X25519()
	var previous []byte
	for round := 1; round <= 2; round++ {
		client, err := curve.GenerateKey(rand.Reader)
		must(err)
		server, err := curve.GenerateKey(rand.Reader)
		must(err)
		message := []byte("ECDH learning probe; not a TLS transcript\x00")
		message = append(message, client.PublicKey().Bytes()...)
		message = append(message, server.PublicKey().Bytes()...)
		signature := ed25519.Sign(signingKey, message)
		check(ed25519.Verify(identity, message, signature), "trusted signature must verify")
		altered := append([]byte(nil), message...)
		altered[len(altered)-1] ^= 1
		check(!ed25519.Verify(identity, altered, signature), "altered share must fail authentication")

		clientSecret, err := client.ECDH(server.PublicKey())
		must(err)
		serverSecret, err := server.ECDH(client.PublicKey())
		must(err)
		check(bytes.Equal(clientSecret, serverSecret), "peers must agree")
		if previous != nil {
			check(!bytes.Equal(previous, clientSecret), "fresh exchange must produce a different observed secret")
		}
		previous = clientSecret
		fmt.Println("round", round, "agrees; altered share fails signature verification")
	}
	_, err = curve.NewPublicKey(make([]byte, 31))
	check(err != nil, "wrong-length X25519 key must fail parsing")
	zero, err := curve.NewPublicKey(make([]byte, 32))
	must(err)
	client, err := curve.GenerateKey(rand.Reader)
	must(err)
	_, err = client.ECDH(zero)
	check(err != nil, "all-zero shared result must be rejected")
	fmt.Println("two fresh exchanges differ with one signing identity; malformed and low-order inputs rejected")
	fmt.Println(runtime.Version())
}

每一轮中,双方计算出的共享机密必须相同;两轮都生成新的交换密钥,因此本次观察到的两个共享结果必须不同,而认证签名始终使用同一对身份密钥。篡改已签署公开份额消息中的一个字节,会使签名验证失败,说明交换材料与身份认证不能各做各的、互不关联。

反向测试还覆盖了公钥输入边界:三十一字节的 X25519 公钥在解析时就被拒绝;长度正确但全零的输入可以通过长度检查,却会在 ECDH 阶段因全零结果而失败。不能把“公钥对象创建成功”当成共享机密一定可用,后续算法调用的错误仍然需要检查和处理。

已核验的 macOS、Go 1.27.0 运行通过全部断言,只输出结果标签。程序为了对照,刻意保留了前一轮共享机密,所以它没有证明安全擦除,也没有通过穷举证明密码学难度。比较观察到的两组结果,不能扩大成对所有随机输出永不重复的数学承诺。

实验分别观察了密钥协商、一轮一换、签名认证和非法输入处理,但没有执行真实 TLS 握手记录、证书链验证、流量密钥派生、报文加密、会话恢复或生产密钥销毁。实际通信应交给持续维护的 TLS 实现,并核验协商模式;把这些原语拼到一起,还不足以构成安全的传输协议。

延伸阅读

来源

  • RFC 8446:TLS 1.3,CertificateVerify、密钥调度、PSK 模式与前向保密边界,已核验 2026-09-09。
  • Go crypto/ecdh,X25519 协商与非法公钥行为,已核验 2026-09-09。
  • Go crypto/ed25519,签名与验证操作,已核验 2026-09-09。