证书透明度(CT)日志与国密 PKI:如何在国密体系中构建可信的证书审计机制
引言:为什么 PKI 需要"透明度"?
2011年,荷兰 CA DigiNotar 被黑客入侵,攻击者利用其根证书签发了超过500张伪造证书,包括 *.google.com 的通配符证书。这些伪造证书被用于对伊朗用户进行中间人攻击。事件最终导致 DigiNotar 破产,但暴露的问题远超一家公司的存亡——整个 PKI 体系的信任模型存在结构性缺陷。
问题的核心在于:在传统的 PKI 体系中,CA 签发的证书对公众是不透明的。任何 CA 都可以为任意域名签发证书,而域名所有者可能完全不知情。
2013年,Google 提出了证书透明度(Certificate Transparency,CT)方案,旨在通过公开日志的方式,让所有证书的签发行为都可被审计。如今,CT 已成为浏览器信任证书的硬性要求——Chrome 要求所有2018年4月后签发的证书必须在 CT 日志中有记录。
但在国密 PKI 体系中,CT 的落地仍然是一个鲜有人讨论的话题。本文试图回答一个关键问题:如何在国密 PKI 体系中构建可信的证书审计机制?
一、CT 日志工作原理回顾
1.1 核心概念
CT 的核心思想很简单:所有证书必须记录在公开、不可篡改的日志中,任何人都可以查询和审计。
CT 日志是一个默克尔树(Merkle Tree)结构的仅追加(append-only)数据结构:
Root Hash
/ \
Hash(0-1) Hash(2-3)
/ \ / \
Hash(0) Hash(1) Hash(2) Hash(3)
| | | |
Cert0 Cert1 Cert2 Cert3关键特性:
- 仅追加:只能添加新证书,不能修改或删除已有记录
- 不可篡改:任何对历史记录的修改都会导致根哈希变化
- 可验证:任何人都可以验证某条证书是否在日志中,以及日志是否被篡改
1.2 CT 生态系统的角色
| 角色 | 职责 | 实例 |
|---|---|---|
| CA(证书颁发机构) | 签发证书并提交到 CT 日志 | CFCA、沃通、数安时代 |
| CT Log(日志服务器) | 存储证书,提供 SCT | Google Argon、Cloudflare Nimbus |
| Monitor(监控者) | 监控日志,发现异常证书 | Facebook CT Monitor |
| Auditor(审计者) | 验证日志的一致性 | 独立第三方 |
| 浏览器/客户端 | 验证 SCT,拒绝无 CT 记录的证书 | Chrome、Firefox |
1.3 SCT:签名的时间戳证明
当 CA 将证书提交到 CT 日志后,日志服务器会返回一个 SCT(Signed Certificate Timestamp),包含:
{
"sct_version": 1,
"id": "CT Log ID (SHA-256 of Log's public key)",
"timestamp": 1700000000000,
"extensions": "",
"signature": "signature over (version, timestamp, type, certificate_data)"
}
}SCT 是日志服务器对"我已收到此证书"的签名承诺。浏览器在验证证书时,会检查是否存在有效的 SCT。
二、国密 PKI 体系中的 CT 需求
2.1 为什么国密 PKI 也需要 CT?
有人认为,国密 PKI 体系主要由政府和企业内部 CA 使用,不像公开互联网 PKI 那样面临恶意 CA 的威胁。这种观点存在以下问题:
第一,内部 CA 同样存在滥发风险。 企业内部 CA 的管理员可能出于测试、便利甚至恶意目的签发不合规的证书。没有透明审计机制,这些行为很难被发现。
第二,国密 HTTPS 的推广需要浏览器信任。 随着国密浏览器(如360安全浏览器、奇安信浏览器)的普及,国密证书也需要像国际证书一样接受透明度审计。
第三,合规要求。 《密码法》和《关键信息基础设施安全保护条例》要求密码应用具备可审计性。CT 是实现证书签发可审计的技术手段。
2.2 国密 CT 的技术挑战
将 CT 应用到国密 PKI 体系,面临以下技术挑战:
挑战一:算法替换
原始 CT 协议(RFC 6962)使用 SHA-256 作为默克尔树的哈希算法,ECDSA 作为签名算法。国密体系需要替换为 SM3 和 SM2:
| 组件 | 国际算法 | 国密算法 |
|---|---|---|
| 默克尔树哈希 | SHA-256 | SM3 |
| 日志服务器签名 | ECDSA (P-256) + SHA-256 | SM2 + SM3(遵循 GM/T 0003-2012) |
| SCT 签名 | ECDSA | SM2 |
| 证书指纹 | SHA-256 | SM3(遵循 GM/T 0004-2012) |
SCT 中的签名需要使用 SM2-with-SM3 算法。SM2 签名的输入格式与 ECDSA 不同(SM2 使用 Z 值,即用户标识和公钥的哈希),需要确保日志服务器和客户端都支持。
挑战三:浏览器/客户端支持
国密浏览器需要支持验证 SM2 签名的 SCT,这涉及到 TLS 扩展和证书验证逻辑的修改。
2.3 国密 CT 的技术方案
完整的国密 CT 方案需要以下改造:
#### 默克尔树改造
# 国际 CT 的默克尔树节点哈希
def merkle_hash(left, right):
return sha256(b'\x01' + left + right).digest()
# 国密 CT 的默克尔树节点哈希
def gm_merkle_hash(left, right):
return sm3_hash(b'\x01' + left + right)
# 叶子节点哈希
def leaf_hash(cert_data):
return sm3_hash(b'\x00' + cert_data)#### SCT 签名的改造
# 国际 SCT 签名数据
def sct_signature_input(version, timestamp, entry_type, cert_data):
return (version.to_bytes(1, 'big') +
timestamp.to_bytes(8, 'big') +
entry_type.to_bytes(2, 'big') +
cert_data)
# 国密 SCT 签名数据(使用 SM2 的 Z 值)
def gm_sct_signature_input(version, timestamp, entry_type, cert_data, log_pubkey):
data = (version.to_bytes(1, 'big') +
timestamp.to_bytes(8, 'big') +
entry_type.to_bytes(2, 'big') +
cert_data)
# SM2 签名需要计算 Z 值
Z = sm3_z_value(user_id="1234567812345678", pubkey=log_pubkey)
return Z + data三、搭建国密 CT 日志服务器
3.1 基于 Trillian 的国密改造
Google 的 Trillian 是 CT 日志服务器的基础框架,支持自定义密码算法。以下是改造步骤:
步骤一:部署 Trillian 基础服务
# 克隆 Trillian
git clone https://github.com/google/trillian.git
cd trillian
# 启动依赖服务(MySQL/MariaDB)
docker-compose up -d mysql
# 初始化数据库
go run ./cmd/createtree --admin_server=localhost:8090
# 启动日志服务器
go run ./cmd/trillian_log_server \
--log_config=examples/ct/config.pb \
--rpc_endpoint=localhost:8090 \
--http_endpoint=localhost:8091步骤二:替换密码算法
Trillian 的密码算法通过 crypto 包抽象,需要实现 SM2/SM3 的 Verifier 和 Signer 接口:
// gm_signer.go
package gm
import (
"crypto"
"github.com/tjfoc/gmsm/sm2"
"github.com/tjfoc/gmsm/sm3"
)
type GMSigner struct {
privateKey *sm2.PrivateKey
}
func (s *GMSigner) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
return s.privateKey.Sign(rand, digest, nil)
}
func (s *GMSigner) Public() crypto.PublicKey {
return &s.privateKey.PublicKey
}
type GMVerifier struct {
publicKey *sm2.PublicKey
}
func (v *GMVerifier) Verifydigest []byte, sig []byte) bool {
return v.publicKey.Verify(digest, sig)
}
// 默克尔树哈希使用 SM3
func MerkleTreeHash(left, right []byte) []byte {
h := sm3.New()
h.Write([]byte{0x01})
h.Write(left)
h.Write(right)
return h.Sum(nil)
}步骤三:生成日志服务器的 SM2 密钥对
from gmssl import sm2, func
# 生成 SM2 密钥对
private_key = sm2.CryptSM2()
private_key.generate_key()
# 导出公钥(用于客户端验证 SCT)
public_key = private_key.get_public_key()
# 保存密钥
with open('ct_log_private.pem', 'w') as f:
f.write(private_key.export_private_key().hex())
with open('ct_log_public.pem', 'w') as f:
f.write(public_key)3.2 最小化部署方案
如果不需要完整的 Trillian 部署,可以使用简化的 CT 日志方案:
# simplified_ct_log.py
"""
简化的国密 CT 日志服务器
基于 Flask + SQLite + SM2/SM3
"""
import json
import sqlite3
import time
from gmssl import sm2, sm3, func
from flask import Flask, request, jsonify
app = Flask(__name__)
class GMCTLog:
def __init__(self, db_path='ct_log.db'):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self._init_db()
self.tree = []
self.log_key = self._load_or_create_key()
def _init_db(self):
self.conn.execute('''
CREATE TABLE IF NOT EXISTS certificates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cert_pem TEXT NOT NULL,
cert_hash TEXT NOT NULL,
leaf_hash TEXT NOT NULL,
timestamp INTEGER NOT NULL,
sct TEXT
)
''')
self.conn.execute('''
CREATE TABLE IF NOT EXISTS sth (
id INTEGER PRIMARY KEY,
tree_size INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
root_hash TEXT NOT NULL,
signature TEXT NOT NULL
)
''')
self.conn.commit()
def _load_or_create_key(self):
"""加载或创建日志服务器的 SM2 密钥对"""
try:
with open('ct_log_key.json', 'r') as f:
key_data = json.load(f)
return sm2.CryptSM2(
private_key=key_data['private_key'],
public_key=key_data['public_key']
)
except FileNotFoundError:
crypt = sm2.CryptSM2()
crypt.generate_key()
key_data = {
'private_key': crypt.export_private_key().hex(),
'public_key': crypt.get_public_key()
}
with open('ct_log_key.json', 'w') as f:
json.dump(key_data, f)
return crypt
def _sm3_hash(self, data: bytes) -> str:
return sm3.sm3_hash(func.bytes_to_list(data))
def _leaf_hash(self, cert_data: bytes) -> str:
return self._sm3_hash(b'\x00' + cert_data)
def _node_hash(self, left: str, right: str) -> str:
return self._sm3_hash(b'\x01' + bytes.fromhex(left) + bytes.fromhex(right))
def _compute_root_hash(self, leaf_hashes: list) -> str:
"""计算默克尔树根哈希"""
if not leaf_hashes:
return self._sm3_hash(b'')
level = leaf_hashes[:]
while len(level) > 1:
next_level = []
for i in range(0, len(level), 2):
left = level[i]
right = level[i + 1] if i + 1 < len(level) else left
next_level.append(self._node_hash(left, right))
level = next_level
return level[0]
def add_cert(self, cert_pem: str) -> dict:
"""添加证书到 CT 日志,返回 SCT"""
cert_data = cert_pem.encode('utf-8')
cert_hash = self._sm3_hash(cert_data)
leaf_hash = self._leaf_hash(cert_data)
timestamp = int(time.time() * 1000)
# 保存证书
cursor = self.conn.execute(
'INSERT INTO certificates (cert_pem, cert_hash, leaf_hash, timestamp) VALUES (?, ?, ?, ?)',
(cert_pem, cert_hash, leaf_hash, timestamp)
)
entry_id = cursor.lastrowid
# 更新默克尔树
self.tree.append(leaf_hash)
root_hash = self._compute_root_hash(self.tree)
tree_size = len(self.tree)
# 生成 SCT 签名
sct_data = (
b'\x00' + # version
timestamp.to_bytes(8, 'big') +
b'\x00\x00' + # entry_type = X.509
cert_data
)
sct_signature = self.log_key.sign(
self._sm3_hash(sct_data).encode('utf-8')
)
sct = {
'version': 0,
'timestamp': timestamp,
'entry_type': 0,
'signature': sct_signature.hex()
}
# 保存 SCT
self.conn.execute(
'UPDATE certificates SET sct = ? WHERE id = ?',
(json.dumps(sct), entry_id)
)
# 保存 STH(Signed Tree Head)
self.conn.execute(
'INSERT OR REPLACE INTO sth (id, tree_size, timestamp, root_hash, signature) VALUES (1, ?, ?, ?, ?)',
(tree_size, timestamp, root_hash, sct_signature.hex())
)
self.conn.commit()
return {
'sct': sct,
'entry_id': entry_id,
'leaf_hash': leaf_hash,
'tree_size': tree_size
}
def get_sth(self) -> dict:
"""获取当前 STH"""
row = self.conn.execute('SELECT * FROM sth WHERE id = 1').fetchone()
if not row:
return None
return {
'tree_size': row[1],
'timestamp': row[2],
'root_hash': row[3],
'signature': row[4]
}
def get_proof(self, cert_hash: str) -> dict:
"""获取证书的包含性证明"""
rows = self.conn.execute(
'SELECT id, leaf_hash FROM certificates ORDER BY id'
).fetchall()
leaf_hashes = [row[1] for row in rows]
target_idx = None
for i, row in enumerate(rows):
if row[1] == cert_hash:
target_idx = i
break
if target_idx is None:
return None
# 计算审计路径
audit_path = []
level = leaf_hashes[:]
idx = target_idx
while len(level) > 1:
next_level = []
for i in range(0, len(level), 2):
left = level[i]
right = level[i + 1] if i + 1 < len(level) else left
next_level.append(self._node_hash(left, right))
sibling_idx = idx ^ 1 # XOR 1 获取兄弟节点索引
if sibling_idx < len(level):
audit_path.append(level[sibling_idx])
level = next_level
idx //= 2
return {
'leaf_index': target_idx,
'audit_path': audit_path,
'root_hash': level[0] if level else ''
}
# Flask API
ct_log = GMCTLog()
@app.route('/ct/v1/add-chain', methods=['POST'])
def add_chain():
data = request.get_json()
certs = data.get('chain', [])
results = []
for cert in certs:
result = ct_log.add_cert(cert)
results.append(result['sct'])
return jsonify({'scts': results})
@app.route('/ct/v1/get-sth', methods=['GET'])
def get_sth():
sth = ct_log.get_sth()
if not sth:
return jsonify({'error': 'no STH available'}), 404
return jsonify(sth)
@app.route('/ct/v1/get-proof-by-hash', methods=['GET'])
def get_proof_by_hash():
cert_hash = request.args.get('hash')
proof = ct_log.get_proof(cert_hash)
if not proof:
return jsonify({'error': 'certificate not found'}), 404
return jsonify(proof)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=6962)四、国密证书的 CT 验证
4.1 验证流程
国密浏览器验证 SM2 证书的 CT 合规性,流程如下:
1. 接收服务器证书链
2. 检查证书中是否包含 SCT(通过 TLS 扩展或 OCSP Stapling)
3. 使用 CT 日志服务器的 SM2 公钥验证 SCT 签名
4. 验证 SCT 中的证书指纹与实际证书匹配
5. 可选:验证证书在默克尔树中的包含性(审计路径验证)4.2 SCT 验证代码
"""国密 SCT 验证"""
from gmssl import sm2, sm3, func
def verify_gm_sct(sct, cert_pem, log_pubkey_hex):
"""验证国密 SCT"""
# 1. 构造签名输入
timestamp = sct['timestamp']
cert_data = cert_pem.encode('utf-8')
sct_data = (
b'\x00' + # version
timestamp.to_bytes(8, 'big') +
b'\x00\x00' + # entry_type
cert_data
)
# 2. 计算 SM3 哈希
digest = sm3.sm3_hash(func.bytes_to_list(sct_data))
# 3. 使用日志服务器的 SM2 公钥验证签名
log_pubkey = sm2.CryptSM2(public_key=log_pubkey_hex, private_key="")
signature = bytes.fromhex(sct['signature'])
return log_pubkey.verify(signature, digest.encode('utf-8'))
def verify_merkle_proof(leaf_hash, audit_path, root_hash, leaf_index):
"""验证默克尔树包含性证明"""
current = bytes.fromhex(leaf_hash)
for i, sibling_hex in enumerate(audit_path):
sibling = bytes.fromhex(sibling_hex)
# 根据索引的比特位决定左右顺序
if (leaf_index >> i) & 1 == 0:
current = sm3_hash(b'\x01' + current + sibling)
else:
current = sm3_hash(b'\x01' + sibling + current)
return current.hex() == root_hash五、国密 CT 的实践建议
5.1 企业内部 PKI 的 CT 部署
对于企业内部 PKI,建议采用以下架构:
┌─────────────┐
│ 内部 CA │
│ (SM2/SM3) │
└──────┬──────┘
│ 签发证书 + 提交到 CT 日志
▼
┌─────────────┐
│ 国密 CT │
│ 日志服务器 │
└──────┬──────┘
│ STH + 审计路径
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Monitor │ │ Auditor │ │ 浏览器 │
│ (监控) │ │ (审计) │ │ (验证) │
└──────────┘ └──────────┘ └──────────┘部署要点:
- CT 日志服务器应部署在独立的网络区域,与 CA 物理隔离
- 日志数据应定期备份,防止数据丢失
- Monitor 服务应配置告警规则,监控异常证书签发
- 审计日志应保留至少 5 年
5.2 与现有国际 CT 的兼容
在过渡期,企业可能同时运行国际算法和国密算法两套 PKI。建议:
- 独立日志:国密证书和国际证书分别提交到不同的 CT 日志
- 统一监控:Monitor 服务同时监控两套日志,统一告警
- 浏览器适配:国密浏览器验证国密 CT,国际浏览器验证国际 CT
5.3 性能考虑
CT 日志的性能瓶颈主要在写入(签名)和读取(审计路径计算):
| 操作 | 国际 CT (ECDSA+SHA-256) | 国密 CT (SM2+SM3) |
|---|---|---|
| 叶子哈希 | ~1μs | ~3μs |
| SCT 签名 | ~2ms | ~5ms |
| 审计路径验证 | ~0.1ms/层 | ~0.3ms/层 |
六、总结
证书透明度是 PKI 体系信任链的重要一环。在国密 PKI 体系中构建 CT 机制,需要:
- 算法替换:将 SHA-256 替换为 SM3,ECDSA 替换为 SM2
- 协议适配:SCT 格式和签名算法需要适配国密标准
- 基础设施:部署国密 CT 日志服务器、Monitor 和 Auditor
- 客户端支持:国密浏览器需要支持 SM2 签名的 SCT 验证
*参考资料:*
- *RFC 6962 - Certificate Transparency*
- *RFC 9162 - Certificate Transparency Version 2.0*
- *GM/T 0003-2012 - SM2 椭圆曲线公钥密码算法*
- *GM/T 0004-2012 - SM3 密码杂凑算法*
- *Google Trillian: https://github.com/google/trillian*
- *Certificate Transparency 中文社区: https://ct.fly.dev*