证书透明度 CT 让公开 TLS 证书的签发变得可观察,但它不判断域名持有者是否授权了某次签发,不代替证书链验证,也不保证世界上所有证书都已经进入日志。一张错误签发的证书也可能带有有效的透明度证据。真正的价值是留下公共线索,供监控方和受影响的持有者调查。

承诺不等于包含证明

SCT 即签名证书时间戳,是日志对已提交证书或预证书作出的签名承诺:在最大合并延迟 MMD 以内把它纳入树中。SCT 不是另一张证书,也不是证明条目已经存在于树中的包含证明。它的时间戳记录日志作出声明的时刻,不一定就是证书实际签发的精确时刻。

默克尔包含证明把某个已经序列化的叶节点,连接到特定大小的树及其根哈希。验证者需要通过相应的已签名日志声明和可信日志公钥认证这个根。如果无条件接受不可信证明一起附带的根,攻击者完全可以自己构造另一棵树;计算结果相同,也没有建立任何有用的外部事实。

一致性证明回答的是另一个问题:后来的树是否在保持既有前缀不变的情况下扩展了早先的树。一次包含证明不能说明全部历史。签名检查点、一致性验证,以及独立观察者之间对不同视图的比较,有助于发现日志向不同人讲述不同历史。哈希让修改可以被这些检查发现,不是物理上禁止运营者改写数据。

Chrome 对公开受信任的 TLS 证书执行自己的 CT 策略,考虑 SCT 的交付方式、日志状态、数量与运营者多样性。这不是每次打开页面都到公共日志里搜索,也不是判断持有者是否申请过证书。RFC 9162 定义了第二版协议并废止 RFC 6962,但规范发布并不能直接证明某个已部署日志实际支持哪一版。

坏掉的样子

SCT 的签名即使有效,如果对应日志不被接受、数量不足或交付方式不符合目标客户端策略,仍然可能无法通过检查。反过来,符合 CT 也修复不了过期、不受信任或名称不匹配的证书。普通 TLS 验证、CT 合规和签发是否获准,是需要分别确认的三个决定。

域名监控也需要结合业务背景解释。新记录可能来自获准接入的 CDN、通配符证书或正常续签;升级告警之前应核对签发者、名称、指纹与内部签发清单。无法解释的签发应进入调查和纠正流程,不能假定“已经写进日志”就代表它被阻止或被确认合法。

公开日志可能暴露证书中的名称,因此不应仅为做实验就提交私有证书或敏感内部命名。监控覆盖不全、数据过旧,也不能证明某次签发不存在。运营上的监控要声明范围和更新时间,不能把一次空搜索结果当成所有日志、所有时间都没有相关证书的保证。

快速验证

保存为 ct_probe.go,使用 Go 1.23 或更新版本运行 go run ct_probe.go。程序离线使用 RFC 6962 的 SHA-256 默克尔构造,输入是三个合成字符串,不是真实证书,也不是已经按 CT 协议序列化的叶节点。它只验证这个固定三叶树中下标为一的中间叶节点,不是通用验证器。

package main

import (
	"crypto/sha256"
	"fmt"
	"runtime"
)

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

func leaf(data string) [32]byte {
	return sha256.Sum256(append([]byte{0}, []byte(data)...))
}

func branch(left, right [32]byte) [32]byte {
	var data [65]byte
	data[0] = 1
	copy(data[1:33], left[:])
	copy(data[33:], right[:])
	return sha256.Sum256(data[:])
}

// Only verifies index 1 of a three-leaf fixture; not a general CT verifier.
func middleOfThree(data string, proof [][32]byte, root [32]byte) bool {
	return len(proof) == 2 && branch(branch(proof[0], leaf(data)), proof[1]) == root
}

func main() {
	a, b, c := leaf("fixture-A"), leaf("fixture-B"), leaf("fixture-C")
	root := branch(branch(a, b), c)
	check(fmt.Sprintf("%x", root) == "111b905d16e9388f6e9079aaec820ab176d649b9daecabcdb3aca45fe4b6b646", "root differs from independent fixture vector")
	check(fmt.Sprintf("%x", leaf("")) == "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d", "empty leaf vector differs")
	check(leaf("") != sha256.Sum256(nil), "an empty tree and an empty leaf are different")
	proof := [][32]byte{a, c}
	check(middleOfThree("fixture-B", proof, root), "correct inclusion proof must pass")
	check(!middleOfThree("fixture-X", proof, root), "altered entry must fail")
	badProof := append([][32]byte(nil), proof...)
	badProof[0][0] ^= 1
	check(!middleOfThree("fixture-B", badProof, root), "altered sibling must fail")
	check(!middleOfThree("fixture-B", [][32]byte{c, a}, root), "sibling order matters")
	check(!middleOfThree("fixture-B", proof[:1], root), "short proof must fail")
	check(!middleOfThree("fixture-B", append(proof, b), root), "extra proof element must fail")
	badRoot := root
	badRoot[0] ^= 1
	check(!middleOfThree("fixture-B", proof, badRoot), "wrong trusted root must fail")
	nextRoot := branch(branch(a, b), branch(c, leaf("fixture-D")))
	check(nextRoot != root, "appending an entry changes this fixture root")
	check(!middleOfThree("fixture-B", proof, nextRoot), "old proof must not verify against the new root")
	fmt.Println("correct proof accepted; altered entry, sibling, order, length, and root rejected")
	fmt.Println("empty tree differs from empty leaf; fourth entry changes root; old proof fails")
	fmt.Printf("three-leaf fixture root: %x\n", root)
	fmt.Println(runtime.Version())
}

叶子哈希先放值为 0 的前缀字节,再放数据;内部节点先放值为 1 的前缀字节,再连接左右子节点哈希。这些前缀把两类输入分开。三个叶节点时,左子树包含 A 与 B,右边单独放 C,不需要把它补成四个节点。空树和只有一个空叶节点的树,也应得到不同哈希。

正确证明只需要 A 与 C 的哈希。把它们与 B 重新组合,会得到预期根;该固定结果还用 Python hashlib 独立交叉核验过。修改 B、相邻节点、证明顺序、长度或预期根,都必须失败。追加 D 会改变本例的树根,旧的三叶树证明也不能再用来匹配这个新根。

已核验的 macOS、Go 1.27.0 运行通过全部断言。硬编码测试根只在本例中代表已经可信的三叶检查点,程序没有实现 SCT 签名、真实日志公钥验证、证书编码、网络查询、通用包含证明或一致性证明。特别是,观察到根变化本身不能证明日志只追加;浏览器策略执行与在线错误签发监控,仍是没有在本例中验证的独立操作。

延伸阅读

来源