你的审计员要求提供你们的 AI 代理上季度所做工作的证据。你给他们一份审计日志的 CSV 导出文件。他们提出一个问题:“你能证明这不是事后编辑的吗?”大多数团队做不到。日志存储在一个可变的数据库中,由生成它的同一系统导出。审计员必须凭信任接受整个记录,而这正是使它不能成为证据的特性。
这篇文章解释了该平台如何构建无需信任即可成立的审计证据。之前的文章讨论了为什么每个代理的身份和防篡改账本对于 Claude Code 和 MCP 部署很重要。这篇文章更深入地探讨了使证据可独立验证的三个属性:哈希链、每个事件的加密签名,以及以审计工具已能识别的机器可读合规格式导出。
哈希链:每个事件都提交所有之前的事件
账本是按租户追加且哈希链式的。每个事件在其 prev_hash 字段中携带上一个事件的 SHA-256 哈希。事件 N 的链哈希是基于一个规范二进制前象计算的,该前象包括:事件 N 自身的字段(租户、序列号、时间戳、执行者、操作、目标、元数据摘要、负载哈希),并与事件 N-1 的 prev_hash 拼接。在序列号为 1 时,prev_hash 全部为零——这是创世锚。
关键属性:修改、插入或删除链中间的任何事件都会改变其哈希,这会破坏下一个事件的 prev_hash 链接,进而破坏下一个事件,依此类推直至链尾。通过遍历链并重新计算每个哈希,单次编辑是可以被检测到的。
原像是一个固定的、带有长度前缀的二进制编码,并带有版本化的域分隔符——不是 JSON。这是针对基于 JSON 的哈希可能暴露的三个攻击面有意做出的选择:
- 键顺序:大多数序列化器无法保证 JSON 对象键的顺序。即使语义相同的数据,不同的键顺序也会产生不同的哈希,这会导致链错误中断——更糟的是,攻击者可以通过重新排列键来伪造匹配的哈希。
- 空白字符和数字格式:
{"seq": 1}、{"seq":1}和{"seq": 1.0}在语义上是等价的 JSON,但会产生不同的 SHA-256 摘要。 - 串联伪造:如果没有长度前缀,两个相邻的短字段可以合并,从而伪造一个具有相同哈希的第三个长字段。给每个字段加上以字节数为单位的长度前缀(4字节大端)可以解决这个问题。
core/internal/store/canon/canon.go 中的实现是唯一的可信来源。Append(写入)和 Verify(读取)都调用相同的 EventHash 函数。不存在第二个实现会产生偏差:
func EventHash(e Event) []byte {
var buf []byte
buf = lps(buf, domainEvent) // "olivares.audit.v1"
buf = lps(buf, e.TenantID)
var seq [8]byte
binary.BigEndian.PutUint64(seq[:], uint64(e.Seq))
buf = append(buf, seq[:]...)
buf = lps(buf, e.OccurredAt)
buf = lps(buf, e.Actor)
buf = lps(buf, e.ActorKind)
buf = lps(buf, e.Action)
buf = lps(buf, e.TargetKind)
buf = lps(buf, e.TargetID)
buf = append(buf, fixed(e.MetaDigest)...)
buf = append(buf, fixed(e.PayloadHash)...)
buf = append(buf, fixed(e.PrevHash)...)
sum := sha256.Sum256(buf)
return sum[:]
}
域分隔符 "olivares.audit.v1" 将哈希绑定到其目的和版本。来自不同域(检查点、负载、元数据摘要)的哈希永远不可能与事件哈希冲突,即使原始字节碰巧相同。
Ed25519 签名:每个事件都是其自己的锚点
哈希链可以证明内部一致性,但不能证明真实性。拥有原始数据库写入权限的攻击者可以从头重新计算整个链,但使用被篡改的事件,从而生成一个有效的链——与原始链不同,但内部一致。哈希链可以检测篡改;它们不能证明来源。
每个事件的 Ed25519 签名完成此 gap。账本中附加的每个事件在写入时都会被签名。签名覆盖租户、序列号和事件链哈希的域分离预映像:
domain ("olivares.audit.event.v1") || tenant || seq (8 bytes, big-endian) || hash
签名存储在事件上,但在设计上被排除在链哈希原像之外。这并非偶然:如果签名被包含在哈希中,签署一个事件将改变它本应证明的哈希。签名证明了哈希,但不会改变它。
仅持有公钥的外部验证者可以单独确认每个事件:从事件的字段重新计算链哈希,重建原像,并验证 Ed25519 签名。如果任何事件在签名后被更改,则该特定事件的签名检查会失败——验证者无需信任生成证据的系统。
该平台还支持密钥轮换。签名密钥在生命周期中途更改的链,通过固定当前密钥以及前几代的公钥来进行端到端验证。verify 函数接受一组候选密钥,如果任何候选密钥能够验证事件,则认为该事件有效。
在每个事件签名之上,定期的检查点会在单独的签名域(olivares.audit.checkpoint.v1)下对链的末端进行公证。对于需要防御主机级别攻击的组织——不仅是数据库级别——检查点可以由一个离线 KMS/HSM 密钥签名(AWS KMS、GCP Cloud KMS、Azure Key Vault),该私钥永远不会保存在主机上。每个事件的签名处理仅针对数据库攻击者;离线检查点处理主机攻击者。两个威胁模型是不同的;单独使用任何一个签名都无法覆盖两者。
账本合约:在同一事务中封存
审计系统中常见的故障是状态变更与审计记录之间的最终一致性。状态发生了变化,但审计写入被排队或批量处理,如果审计写入失败,状态变更已经提交。结果是:存在于系统中但没有证据的未经审计的变更。
该平台执行更严格的合约。状态变更和账本封存都发生在同一数据库事务中。如果封存失败,整个事务回滚——状态变更永远不会提交。这不是尽力而为;它是拒绝关闭型。
会话运行时账本说明了这一点。当 Claude Code 会话状态发生变化(已创建、已启动、正在停止、已停止、失败)时,appendRunEvent 会原子地在两个地方记录该转换:
- 全局哈希链审计账本 通过
sc.Audit().Append—— 这是由PayloadHash锚定的防篡改链。 - 每会话可查询账本 —— 这是一个仅追加投影,由
audit_seq连接到全局链。
这两个写入均发生在调用者的 Mutate 事务内。runtime_ledger.go 中的代码注释直接说明了设计意图:“账本是记录系统,因此如果密封失败,整个转换将回滚 —— 它不是尽力而为。”
PayloadHash 本身仅记录规范的、非敏感的转换事实——运行参考、序列、事件类型、状态转换和时间戳。它从不包含转录内容、提示、环境值或机密。账本证明的是发生了什么;它不存储说了什么。
同样的模式也适用于 workspace_ledger.go 中的工作区文件变更。文件写入、创建目录、移动或删除操作在文件系统操作执行之前就被封存。如果证据无法追加,变更操作将不会执行。封存信息包含操作类型、路径以及所写内容的 SHA-256——从不包含内容字节本身。
OSCAL 导出:审计工具可读取的机器可读证据
对于审计员来说,防篡改账本是必要的,但还不够。如果证据采用专有格式,审计员仍然依赖您的工具来解释它。OSCAL——由NIST维护的开放安全控制评估语言(Open Security Controls Assessment Language, OSCAL)——就是关闭这一gap的格式。
该平台将密封的证据包导出为一个OSCAL捆绑包,包含三个模型:
- 组件定义:控制平面的能力,以针对合规框架(NIST SP 800-53、ISO 27001、欧盟人工智能法案等)实施的要求形式表示。每个已实施的要求都包含控制ID、证明其的能力键以及作为自定义属性的实际状态。
- 评估结果:每个控制项的发现都具有符合 OSCAL 的状态。每个发现的目标带有
satisfied或not-satisfied,并且在原因字段中保留了精确的产品状态。 - 控制映射:从框架控制到平台能力参考模型的对应关系,使用 OSCAL 1.2.0 控制映射模型。该关系始终为
intersects-with——能力仅涉及控制的部分内容。它从不断言符合性;该断言仅存在于评估结果中,并依赖实时的操作证据。
在 OSCAL 导出中,诚实性约束值得明确说明。OSCAL 的发现状态枚举恰好有两个值:satisfied 和 not-satisfied。没有“partial”或“按设计”。部分实现的控制、按设计处理的控制、有漏洞的控制或 unmapped 映射到 OSCAL not-satisfied,实际产品状态记录在 status.reason 中,并且在平台自有命名空间下有一个自定义属性(https://olivares.ai/ns/oscal)。导出绝不会将部分满足的控制转换为 satisfied。只有在封存时有实际操作证据支持的控制,才会收到 OSCAL satisfied。
每个 OSCAL 文档都具有账本锚定属性:清单哈希、封存时的账本序列号、账本哈希以及完整性验证结果。这些是审计员在其 GRC 工具中读取的 OSCAL 文档与他们可以独立验证的防篡改链之间的桥梁。
离线验证具体意味着什么
“离线验证”不是一个营销术语。它描述了一种特定的技术程序:验证者将导出的证据在隔离网络的计算机上运行验证工具,并在不连接生成证据系统的任何网络的情况下确认证据的完整性。
该平台的归档导出会写入一个 JSONL 段的目录树(每个事件一行,标准 JSON),以及每个段的清单。清单记录段的序列范围、事件数量、第一个和最后一个链哈希、事件文件的 SHA-256,以及用于跨段连续性的前一个段的最后哈希。
然后离线验证器(VerifyArchiveDir)完全在常数内存下、不进行网络调用地执行以下操作:
- 加载清单并将其与事件文件配对。 独立的事件文件(没有清单)或事件文件缺失的清单都是失败。证据单位是该对。
- 逐行流式处理每个事件文件。 对于每个事件,使用与实时系统相同的
EventHash函数从存档字段重新推导链哈希。将其与存储的哈希进行比较。检查prev_hash链接性和序列的连续性。 - 验证规范性。 对每行解析后的内容重新编组,并确认它产生的字节与磁盘上的字节完全相同。这可以防止未知字段走私或重复键攻击,这些攻击可能通过哈希检查但携带隐藏数据。
- 验证每个事件的 Ed25519 签名。 对于每个非检查点事件,重建签名预映像并根据固定的公钥进行验证。
- 验证检查点签名。 对于每个检查点事件,在检查点域下使用固定的检查点密钥验证签名。如果只固定了事件密钥(没有检查点密钥),该检查点事件将被标记为“无法验证”——拒绝关闭,不可跳过。
- 检查跨段连续性。 确认每个段的第一个序列紧接前一个段的最后一个序列加一,并且前一个段的最后哈希与当前段的
prev_segment_last_hash相匹配。 - 验证事件文件摘要。 在流式计算过程中生成的事件文件 SHA-256 必须与清单的
events_sha256相匹配。
验证器报告它发现的第一个不一致,包括具体的序列号和机器可读的原因:hash-mismatch、prev-mismatch、seq-gap、event-sig-invalid、event-sig-missing、checkpoint-sig-invalid、count-mismatch、events-sha256-mismatch 或 segment-link-mismatch。
一个诚实的限制:离线验证器仅证明它检查的范围,而不包括范围之外的内容。被移除的前缀或尾部在离线状态下无法检测——目录不会说明链从哪里开始或结束。验证报告包括每个租户的 Ranges 字段和 StartsMidChain 标志,因此审计员能够准确知道已证明的内容。实时系统的链内签名检查点覆盖了尾部;离线导出覆盖了存档范围。它们共同形成完整的证明。
| 层 | 它证明了什么 | 它未能证明的内容 |
|---|---|---|
| 哈希链 | 内部一致性;任何编辑都会破坏链 | 来源(谁撰写了事件) |
| 每个事件 | 来源;每个事件都由密钥持有者签名 | 防御主机级别的入侵 |
| 盒外检查点 | 主机入侵抵抗(KMS/HSM 密钥从未在主机上) | 每个事件的粒度(仅涵盖检查点) |
| OSCAL 导出 | 机器可读的、框架映射的合规证据 | 并非每个控制都完全满足(仅实时证据有效) |
| 归档验证 | 离线重新推导上述所有内容 | 导出范围前后的事件 |
代码路径:从状态变更到封装的证据
从 Claude Code 会话状态变化到可验证证据的序列涉及三个层次。在 runtime_ledger.go 中,appendRunEvent 函数通过 SHA-256 对规范长度前缀的转换字段进行哈希,构建 PayloadHash:
func runEventPayloadHash(runRef string, seq int64, event, from, to, detail, atTS string) [32]byte {
h := sha256.New()
for _, part := range []string{
runRef, strconv.FormatInt(seq, 10), event, from, to, detail, atTS,
} {
_, _ = h.Write([]byte(strconv.Itoa(len(part))))
_, _ = h.Write([]byte{':'})
_, _ = h.Write([]byte(part))
}
var sum [32]byte
copy(sum[:], h.Sum(nil))
return sum
}
然后将该哈希传递给 sc.Audit().Append,该函数分配下一个租户序列号,链接到上一个事件的哈希值,通过 EventHash 计算该事件的链哈希,用 Ed25519 签名,并插入封装事件——所有操作都在调用者的事务中完成。
结果:在事务提交时,会话的状态更改及其防篡改审计记录要么同时持久化,要么同时回滚。不存在状态已更改而证据未更新的情况。