实现原理:CAA 与 DNS
对应代码:
pkg/caa.go· 入口CheckCAA(target)
CAA(Certification Authority Authorization,RFC 6844)让域名所有者在 DNS 里声明"只允许哪些 CA 给我签证书"。cert-skills 检查证书的签发 CA 是否被域名 CAA 授权。
为什么手搓 DNS
Go 标准库的 DNS 解析器不认 CAA 记录(type 257),所以 cert-skills 自己拼 DNS 报文走裸 UDP。
DNS 报文构造
dnsQueryCAA(L115)拼 12 字节头 + 查询体:
12 字节 DNS 头
| 偏移 | 字节 | 含义 |
|---|---|---|
| 0-1 | 0xAA 0xBB | 事务 ID(固定) |
| 2-3 | 0x01 0x00 | flags:标准查询,RD=1 |
| 4-5 | 0x00 0x01 | QDCOUNT=1 |
| 6-7 | 0x00 0x00 | ANCOUNT=0 |
| 8-9 | 0x00 0x00 | NSCOUNT=0 |
| 10-11 | 0x00 0x00 | ARCOUNT=0 |
QNAME 编码(L128-135)
每段前缀 1 字节长度字节,末尾追加 0x00。
QTYPE / QCLASS
- QTYPE =
0x01 0x01(CAA = 257 = 0x0101,大端) - QCLASS =
0x00 0x01(IN)
DNS 服务器与超时
- 默认服务器:
["8.8.8.8:53", "1.1.1.1:53", "8.8.4.4:53"](caaDNSServers可测试覆写) DialTimeout= 3 秒conn.SetDeadline= now + 5 秒- 响应
n<12→ "DNS response too short",继续下一个 server - 逐个尝试,首个成功即返回
响应解析
skipDNSName 压缩指针处理(L265-280)
解析器只跳过名字、不跟踪指针指向的内容——best-effort 设计。
RDATA 拆解
仅 rtype==257 && len(rdata)>=2 才解析:
go
flag = rdata[0]
tagLength = int(rdata[1])
tag = string(rdata[2 : 2+tagLength])
value = string(rdata[2+tagLength:])
// 要求 2+tagLength <= len(rdata)合规判定
checkCAACompliance(L283)的逻辑:
extractCAName(L337-358)
O= 优先,CN= 回退。
caaDomainMatches(L363-383)双向子串匹配
双方 ToLower 后,三种匹配任一中即 true:
边界情况需人工复核
双向子串匹配对相似域名(如 letsencrypt.org vs letsencrypt.com)可能误判,命中后请人工复核实际 CAA 记录。
issuewild 未参与校验
issuewild 与 iodef 仅分类收集,不产生 violation——当前实现中 issuewild 管 *. 泛解析但未用于校验逻辑。
输出
go
type CAAResult struct {
Target string
HasCAA bool
Records []CAARecord
IssuerCA string
IsCompliant bool
Violations []string
Error string
}
type CAARecord struct {
Flag uint8
Tag string // issue / issuewild / iodef
Value string
}无 CAA 记录时 HasCAA=false, IsCompliant=true(任何 CA 可发行)。
实战意义
CAA 不是绝对防护(能改 DNS 就能改 CAA),但它给 CA 多了一道签发前检查,且 CAA 变更可被监控。
局限
- DNS 路径信任:UDP DNS 无认证,理论可被中间人改包。生产应走 DoH/DoT(cert-skills 当前用裸 UDP)
- 模糊匹配:双向子串匹配有边界情况,需人工复核