透明日志服务¶
本文是英文版的参考译文,更新于 25/10/2026。如有出入,以英文版为准。标识符、命令和代码块保持原文,不作翻译。
受 Certificate Transparency(CT)启发的 HTTP 透明日志服务。它把 bundle 哈希存储在只追加的 RFC 6962 Merkle 树中,提供可验证的包含证明和以 Ed25519 签名的树头。状态保存在内存中:重启后会从一棵新的空树开始(参见持久化)。
快速开始¶
# Start the service, from the root of a checkout after `npm install` there
node services/transparency-log/index.js
# Or with Docker, from the repo root: the Compose stack runs two logs that gossip with each other
docker compose -f docker/docker-compose.yml up transparency-log-a transparency-log-b
该服务默认监听 http://localhost:3002。
端点¶
GET /health¶
服务健康检查。
{
"ok": true,
"service": "dcp-transparency-log",
"log_id": "log-alpha",
"public_key": "base64 Ed25519 public key that signs STHs",
"size": 42,
"gossip_peers": 1,
"alerts": 0
}
POST /add¶
向日志添加一个 bundle 哈希:sha256: 后跟 64 个小写十六进制字符。其他任何内容,或超过 1 MB 的请求体,都会返回 400。
curl -X POST http://localhost:3002/add \
-H "Content-Type: application/json" \
-d '{"bundle_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}'
GET /root¶
获取日志当前的 Merkle 根。
GET /root/signed¶
获取已签名树头(Signed Tree Head,STH):对 { log_id, root, size, timestamp } 的规范化 JSON(键已排序,
tools/crypto.js 中的 signObject)所作的 Ed25519 签名(base64)。
{
"log_id": "log-alpha",
"root": "d4e5f6...",
"size": 43,
"timestamp": "2025-01-01T00:00:00.000Z",
"signature": "base64...",
"public_key": "base64..."
}
请对照你通过带外方式固定的公钥(启动时打印,也显示在 /health 中)进行验证,绝不要只对照 STH 自带的 public_key:
import { verifyObject } from "./tools/crypto.js";
const { log_id, root, size, timestamp, signature } = sth;
verifyObject({ log_id, root, size, timestamp }, signature, pinnedPublicKey); // true
OPERATOR_KEY 是密钥的 32 字节种子,因此签名密钥在重启后仍然保留;树不会保留,因为它在内存中。没有它时,日志在每次重启后
都会用一个新的临时密钥签名:如果你归档或固定了旧密钥,之前的 STH 仍可用旧密钥验证,但不能用新密钥验证。
GET /proof/:index¶
按索引获取某个条目的 Merkle 包含证明。
{
"index": 5,
"leaf_hash": "a1b2c3...",
"entry": {
"hash": "sha256:abc123...",
"leaf_hash": "a1b2c3...",
"timestamp": "2025-01-01T00:00:00.000Z",
"index": 5
},
"root": "d4e5f6...",
"proof": [
{ "hash": "x1y2z3...", "direction": "left" },
{ "hash": "m4n5o6...", "direction": "right" }
]
}
借助该证明,无需下载整个日志即可验证某个条目包含在 Merkle 树中。
GET /entries¶
列出日志的所有条目。
{
"entries": [
{ "hash": "sha256:...", "leaf_hash": "...", "timestamp": "...", "index": 0 },
{ "hash": "sha256:...", "leaf_hash": "...", "timestamp": "...", "index": 1 }
],
"size": 2
}
Gossip¶
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /gossip/peers |
已知的对等节点和最近 20 条告警 |
| POST | /gossip/peers |
添加一个对等节点 { peer_id, endpoint, public_key }(http/https 端点,base64 Ed25519 密钥)。需要 Authorization: Bearer <OPERATOR_TOKEN>:日志会自行访问对等节点,因此开放的列表会导致 SSRF |
| POST | /gossip/exchange |
与已固定的对等节点交换 STH { sth };返回我方的 STH 以及任何分叉视图告警 |
| GET | /gossip/alerts |
分叉视图告警(最近 100 条) |
只有能用对等节点已固定的公钥验证的树头才算数:在 GOSSIP_PEERS 中以 <endpoint>#<base64 Ed25519 public key> 的形式给出,
或在 POST /gossip/peers 中作为 public_key 给出。来自未固定密钥的对等节点的树头、格式错误的树头以及签名错误的树头都会被
忽略。一次轮询最多读取 16 KB,超过一个间隔(最多 10 秒)就放弃,并且在上一次轮询仍在进行时不会开始。/gossip/exchange
是开放的,因此它只接受由已固定的对等节点签名、格式正确的 STH(64 位十六进制或 null 的 root,整数 size),请求体最多
16 KB(否则返回 400 或 403)。告警按对等节点、树大小和根去重,因此重放已签名的树头不会增加任何内容,并且只保留最近 100 条。
配置¶
环境变量¶
| 变量 | 默认值 | 说明 |
|---|---|---|
PORT |
3002 |
HTTP 端口 |
LOG_ID |
随机 | 此日志实例的标识符 |
OPERATOR_KEY |
— | 签署 STH 的密钥的 Ed25519 种子:64 个十六进制字符(openssl rand -hex 32)。任何其他值都会使服务停止;未设置时使用临时密钥 |
OPERATOR_TOKEN |
— | POST /gossip/peers 的 Bearer 令牌。未设置时锁定 |
GOSSIP_PEERS |
— | 以逗号分隔的对等节点,每个为 <endpoint>#<base64 Ed25519 public key>(对等节点 /health 中的 public_key,由其 OPERATOR_KEY 决定),每 GOSSIP_INTERVAL_MS 轮询一次 |
GOSSIP_INTERVAL_MS |
30000 |
Gossip 轮询间隔 |
CT 风格的格式¶
该日志遵循受 Certificate Transparency(RFC 6962)启发的格式:
- 只追加: 条目永远不会被删除或修改
- Merkle 树: RFC 6962 §2.1。
leaf_hash = SHA-256(0x00 || UTF-8(entry.hash)),内部节点为SHA-256(0x01 || left || right),因此叶子永远无法冒充子树。n片叶子的树在小于n的最大 2 的幂处分割;最后一个 节点永远不会被复制。 - 包含证明: 任何人都可以用证明来验证某个条目存在于日志中
- 已签名的根: 以 Ed25519 签名的树头(
GET /root/signed)
验证包含证明¶
要验证某个条目在日志中:
- 用
GET /proof/:index获取证明 - 计算
leaf_hash = SHA-256(0x00 || UTF-8(entry.hash)) - 按顺序以原始字节(解码后的十六进制字符串)重新组合证明节点:
- 如果
direction == "left":hash = SHA-256(0x01 || proof_hash || current) - 如果
direction == "right":hash = SHA-256(0x01 || current || proof_hash) - 最终结果必须等于这样一个已签名树头的
root:它能用日志已固定的公钥验证,并且覆盖构建该证明时的那棵树(相同大小)。 证明响应中的root本身只是自我声明,而一旦添加了条目,更新的根就不再匹配
开发¶
# Start in development mode, from the root of a checkout
PORT=3002 node services/transparency-log/index.js
# Test
curl http://localhost:3002/health
curl -X POST http://localhost:3002/add \
-H "Content-Type: application/json" \
-d '{"bundle_hash": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"}'
curl http://localhost:3002/root
curl http://localhost:3002/proof/0
许可证¶
Apache-2.0