98 字节证明你的文档曾经存在

发布: (2026年2月21日 GMT+8 12:48)
13 分钟阅读
原文: Dev.to

Source: Dev.to

(请提供需要翻译的正文内容,我才能为您完成翻译。)

Source:

ATL 协议检查点 – 固定大小的线格式

ATL 协议 中,检查点是透明日志状态的已签名快照。
它捕获了 Merkle 树在特定树大小、特定时间点、特定日志实例下的根哈希。
只要拥有检查点并且相应的签名验证通过,就能确定日志在该时刻的精确状态。

注意 – 整个线格式固定为 98 字节,没有可变长度字段,也 不需要解析器

字节布局

偏移大小(字节)字段
018魔术字节: "ATL-Protocol-v1-CP" (ASCII)
1832来源 ID(实例 UUID 的 SHA‑256)
508树大小(u64,小端序)
588时间戳(u64,小端序,Unix 纳秒)
6632根哈希(SHA‑256)
总计98(已签名的 blob)

Ed25519 签名(64 字节)和 密钥 ID(32 字节)是 单独存储 的——它们 属于这 98 字节的 blob。此分离是有意的设计决定(如下所述)。

为什么使用固定大小的二进制 Blob?

1. 在签名上下文中没有歧义

常见的序列化格式(JSON、Protobuf、CBOR、MessagePack)非常适合 API 和配置,但在必须进行密码学签名的数据场景下会引入 歧义

格式歧义来源
JSON键的顺序可能不同 → 同一逻辑对象会产生不同的字节序列(因此有 RFC 8785)。
Protobuf字段顺序在技术上未定义;不同实现可能输出不同的字节顺序。
CBOR同一数值存在多种合法编码。
MessagePack与 CBOR 类似——存在多种规范形式。

当你对检查点进行签名时,需要精确知道自己签了什么:“这恰好 98 字节”。如果序列化存在歧义,验证也会变得模糊,两个实现可能产生不兼容的签名。

2. 零解析开销

一个 98 字节的 Blob 可以在任何语言中确定性读取:

  1. 读取 18 字节 → magic。
  2. 读取 32 字节 → origin ID。
  3. 读取 8 字节(小端序)→ tree size。
  4. 读取 8 字节(小端序)→ timestamp。
  5. 读取 32 字节 → root hash。

无需长度前缀、分隔符或 TLV 结构。字节本身就是规范形式。

Rust 实现 – to_bytes()

pub fn to_bytes(&self) -> [u8; CHECKPOINT_BLOB_SIZE] {
    let mut blob = [0u8; CHECKPOINT_BLOB_SIZE];
    blob[0..18].copy_from_slice(CHECKPOINT_MAGIC);
    blob[18..50].copy_from_slice(&self.origin);
    blob[50..58].copy_from_slice(&self.tree_size.to_le_bytes());
    blob[58..66].copy_from_slice(&self.timestamp.to_le_bytes());
    blob[66..98].copy_from_slice(&self.root_hash);
    blob
}
  • 无分配 – 栈分配的数组。
  • 无错误路径 – 返回类型是 [u8; 98],而不是 VecResult
  • 确定性 – 始终生成签名时的精确数据。

签名与密钥 ID – 分开存储

签名密钥 ID 属于已签名的 blob。

为什么要分开存储?

  • 先有鸡还是先有蛋的问题 – 不能对已经包含自身签名的数据再进行签名。
  • 双格式风险 – 许多系统会定义“签名输入”(不含签名的 blob)和“存储格式”(blob + 签名)。这会为同一逻辑对象产生两套序列化规则,导致在验证时使用错误的格式而产生 bug。

验证示例

pub fn verify(&self, verifier: &CheckpointVerifier) -> AtlResult {
    // Fast‑reject on key‑ID mismatch (cheap SHA‑256 compare)
    if self.key_id != verifier.key_id {
        return Err(AtlError::InvalidSignature(format!(
            "key_id mismatch: checkpoint has {}, verifier has {}",
            hex::encode(self.key_id),
            hex::encode(verifier.key_id)
        )));
    }

    // Re‑create the exact signed blob
    let blob = self.to_bytes();
    verifier.verify(&blob, &self.signature)
}
  • 密钥 ID 检查(公钥的 SHA‑256)在昂贵的 Ed25519 验证之前进行,提供了一条快速拒绝的路径。

Magic Bytes – “ATL‑Protocol‑v1‑CP”

前 18 个字节有两个作用:

  1. 格式识别 – 如果将 JPEG、Protobuf 消息或随机的 98 字节缓冲区输入到检查点解析器,魔术字节将不匹配,从而产生明确的 InvalidCheckpointMagic 错误,而不是出现模糊的下游失败。
  2. 版本控制 – 魔术字符串中嵌入的 v1 将线格式版本与数据本身绑定。如果格式将来发生变化(例如新增字段、使用不同的哈希算法),可以将魔术字符串更新为 "ATL-Protocol-v2-CP"。v1 解析器在遇到 v2 检查点时会干净利落地拒绝,而不是误解字节内容。

18 字节的魔术字符串相对宽裕,但它为未来的扩展提供了充足空间,同时保持格式简洁且毫不含糊。

TL;DR

  • 98‑byte fixed binary → 没有歧义,没有解析复杂性。
  • Signature & key ID stored separately → 避免先有鸡还是先有蛋以及双格式的陷阱。
  • Magic bytes → 用于格式识别 + 版本控制。
  • Rust to_bytes() → 确定性、无分配、始终是签名数据。

这种设计事后看起来显而易见,但许多实现通过混合可变长度编码或混淆签名数据与存储表示而出错。保持签名 blob 不可变且最小化可以消除这些类别的错误。

魔术字节(十六进制表示)

检查点 blob 以可读的字符串开头,而不是短的二进制魔数。
魔术字节为:

41544C2D50726F746F636F6C2D76312D4350

它对应的 ASCII 文本是 ATL-Protocol-v1-CP
使用可读的字符串可以让在十六进制转储、日志文件和调试会话中更容易发现该 blob。

时间戳

  • 字段类型: u64
  • 编码: Unix 纳秒(而非秒或毫秒)

u64 在纳秒范围内的时间跨度从 1970 年一直到大约 2554 年,足够使用。

为什么使用纳秒精度?
透明日志可以在同一毫秒内处理多个条目。如果时间戳仅有毫秒分辨率,两个检查点可能会出现相同的时间戳,导致顺序不明确。纳秒分辨率即使在相隔微秒的条目之间也能保证唯一的时间戳。

时间戳由 current_timestamp_nanos() 生成,并被限制在 u64::MAX,以处理(理论上)系统时间超出可表示范围的情况。

Little‑Endian 编码

两个 u64 字段(树大小和时间戳)采用 小端序 编码。

  • 这是一项明确的设计选择,而非默认行为。
  • 现代硬件(x86、ARM 默认、RISC‑V)采用小端序,因此在最常见的平台上将 u64 编码为小端序是 不做任何操作 的。
  • 它消除了在这些平台上整类字节交换错误。

Test for Endianness

// test_endianness: 0x0102_0304_0506_0708 encodes as
// [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]

test_endianness 测试之所以存在,是因为字节序在某个平台上可能看似正确,而在另一个平台上却悄然错误。它记录并验证了该编码作为格式属性的正确性。

可读的 JSON 表示

检查点需要一种可读的形式,以便在 API、调试以及不善于处理原始二进制的存储系统中使用。

  • 提供了一个 CheckpointJson 结构体,使用字符串编码:

    • 哈希值: "sha256:"
    • 签名: "base64:"
  • 转换方法:

    • to_json() – 二进制检查点 → JSON
    • from_json() – JSON → 二进制检查点

重要提示: 加密操作始终在 98 字节的二进制块 上进行,绝不在 JSON 上进行。Ed25519 签名是基于 to_bytes() 计算的,而不是基于 serde_json::to_string()

为此,主结构体 Checkpoint 没有派生 SerializeDeserialize。直接尝试序列化它会导致编译时错误,强制调用方使用显式的转换方法。

签名信任模型 (ATL Protocol v2.0)

  • 对检查点的 Ed25519 签名是一个 完整性检查而非 信任锚点。
  • 它证明:“此检查点是由持有该私钥的主体签发的。”
  • 证明:“你应该信任此密钥。”

外部信任锚点

  1. RFC 3161 TSA 时间戳 – 受信任的第三方时间戳授权机构证明检查点存在的时间。
  2. Bitcoin OTS – 检查点哈希被锚定在比特币区块链中,提供不可篡改的时间戳,任何单一方都无法伪造。

这些锚点确定 检查点何时存在;Ed25519 签名仅将检查点绑定到特定的日志实例。

后果: 如果 Ed25519 签名密钥随后被泄露,已经锚定的过去检查点仍然可信,因为外部锚点独立于签名密钥。

测试套件(Wire‑Format 覆盖)

测试目的
test_checkpoint_blob_size验证 blob 恰好为 98 字节
test_magic_bytes检查前 18 字节等于 "ATL-Protocol-v1-CP"
test_endianness确认 u64 字段使用小端序编码。
test_wire_format_layout确保每个字段位于正确的字节偏移。
test_sign_and_verify循环往复:创建 checkpoint → 签名 → 验证。
test_verify_wrong_key_fails使用密钥 A 的签名 能用密钥 B 验证。
test_verify_tampered_data_fails在 checkpoint 中翻转一个字节会导致验证失败。
test_verify_tampered_signature_fails在签名中翻转一个字节会导致验证失败。
test_json_roundtrip二进制 → JSON → 二进制,产生相同的字节。
test_empty_tree_checkpointtree_size = 0 的 checkpoint 是有效的。

每个测试名称都记录了格式的特定属性;测试失败时会立即告诉你 出了什么问题 以及 为什么这很重要

Blob Size Breakdown (Why 98 bytes?)

BytesMeaning
18魔术字节 / 格式标识符 ("ATL-Protocol-v1-CP")。
32来源标识符(哪个日志实例)。
8树的大小(条目数量)。
8时间戳(纳秒)。
32根哈希(对整个日志的加密承诺)。
总计: 98 字节(签名声明)。

所有 98 字节都是必不可少的:

  • 魔术字节 防止误识别。
  • 来源 避免跨日志混淆。
  • 树的大小时间戳 确定快照在日志历史中的位置。
  • 根哈希 对每一条已写入的条目进行承诺。

删除任何字段都会使检查点变得模糊或可伪造。

什么 在已签名的 Blob 中

超出 98 字节的任何内容——例如签名本身、密钥 ID、元数据或注释——都属于 外部。已签名的 Blob 是不可变的声明;其他所有内容都是注释。

实现细节

  • 仓库: (Apache‑2.0)
  • 讨论的文件: src/core/(检查点实现位于此处)。
# `checkpoint.rs`

**Description**  
- Implements the checkpoint wire format.  
- Handles serialization, signing, and verification.  

**Details**  
- **File size**: 1080 lines of Rust code.  
- **Signature scheme**: Ed25519 signatures using the **`ed25519-dalek`** crate.  
0 浏览
Back to Blog

相关文章

阅读更多 »

Steel Bank Common Lisp

关于 Steel Bank Common Lisp(SBCL),它是一款高性能的 Common Lisp 编译器。它是开源/自由软件,采用宽松的许可证。除此之外,...