跳至正文

机器翻译。英文版本为权威来源,母语审校尚未完成。

audit

审计证据,验证者可以离线检查

作者 Olivares AI 7 分钟阅读

你的审计员要求提供你们的 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 会原子地在两个地方记录该转换:

  1. 全局哈希链审计账本 通过 sc.Audit().Append —— 这是由 PayloadHash 锚定的防篡改链。
  2. 每会话可查询账本 —— 这是一个仅追加投影,由 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 的状态。每个发现的目标带有 satisfiednot-satisfied,并且在原因字段中保留了精确的产品状态。
  • 控制映射:从框架控制到平台能力参考模型的对应关系,使用 OSCAL 1.2.0 控制映射模型。该关系始终为 intersects-with——能力仅涉及控制的部分内容。它从不断言符合性;该断言仅存在于评估结果中,并依赖实时的操作证据。

在 OSCAL 导出中,诚实性约束值得明确说明。OSCAL 的发现状态枚举恰好有两个值:satisfiednot-satisfied。没有“partial”或“按设计”。部分实现的控制、按设计处理的控制、有漏洞的控制或 unmapped 映射到 OSCAL not-satisfied,实际产品状态记录在 status.reason 中,并且在平台自有命名空间下有一个自定义属性(https://olivares.ai/ns/oscal)。导出绝不会将部分满足的控制转换为 satisfied。只有在封存时有实际操作证据支持的控制,才会收到 OSCAL satisfied

每个 OSCAL 文档都具有账本锚定属性:清单哈希、封存时的账本序列号、账本哈希以及完整性验证结果。这些是审计员在其 GRC 工具中读取的 OSCAL 文档与他们可以独立验证的防篡改链之间的桥梁。

用于证明流水线的活动

离线验证具体意味着什么

“离线验证”不是一个营销术语。它描述了一种特定的技术程序:验证者将导出的证据在隔离网络的计算机上运行验证工具,并在不连接生成证据系统的任何网络的情况下确认证据的完整性。

该平台的归档导出会写入一个 JSONL 段的目录树(每个事件一行,标准 JSON),以及每个段的清单。清单记录段的序列范围、事件数量、第一个和最后一个链哈希、事件文件的 SHA-256,以及用于跨段连续性的前一个段的最后哈希。

然后离线验证器(VerifyArchiveDir)完全在常数内存下、不进行网络调用地执行以下操作:

  1. 加载清单并将其与事件文件配对。 独立的事件文件(没有清单)或事件文件缺失的清单都是失败。证据单位是该对。
  2. 逐行流式处理每个事件文件。 对于每个事件,使用与实时系统相同的 EventHash 函数从存档字段重新推导链哈希。将其与存储的哈希进行比较。检查 prev_hash 链接性和序列的连续性。
  3. 验证规范性。 对每行解析后的内容重新编组,并确认它产生的字节与磁盘上的字节完全相同。这可以防止未知字段走私或重复键攻击,这些攻击可能通过哈希检查但携带隐藏数据。
  4. 验证每个事件的 Ed25519 签名。 对于每个非检查点事件,重建签名预映像并根据固定的公钥进行验证。
  5. 验证检查点签名。 对于每个检查点事件,在检查点域下使用固定的检查点密钥验证签名。如果只固定了事件密钥(没有检查点密钥),该检查点事件将被标记为“无法验证”——拒绝关闭,不可跳过。
  6. 检查跨段连续性。 确认每个段的第一个序列紧接前一个段的最后一个序列加一,并且前一个段的最后哈希与当前段的 prev_segment_last_hash 相匹配。
  7. 验证事件文件摘要。 在流式计算过程中生成的事件文件 SHA-256 必须与清单的 events_sha256 相匹配。

验证器报告它发现的第一个不一致,包括具体的序列号和机器可读的原因:hash-mismatchprev-mismatchseq-gapevent-sig-invalidevent-sig-missingcheckpoint-sig-invalidcount-mismatchevents-sha256-mismatchsegment-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 签名,并插入封装事件——所有操作都在调用者的事务中完成。

结果:在事务提交时,会话的状态更改及其防篡改审计记录要么同时持久化,要么同时回滚。不存在状态已更改而证据未更新的情况。

链接

相关文章

常见问题

攻击者如果入侵数据库,能否重新签名伪造事件?

每个事件的 Ed25519 签名可防御仅数据库层面的妥协:被盗备份、注入行,或具有 RLS 绕过角色的副本。签名密钥存在于数据目录中,而不在数据库中。对于主机妥协的情况,平台支持通过 KMS/HSM(AWS KMS、GCP Cloud KMS、Azure Key Vault)进行的离箱检查点签名,其中私钥永远不会存在于主机上。每事件签名可以阻止数据库级攻击;离箱检查点可以阻止主机级攻击。两者单独使用都不能覆盖这两种威胁模型。

如果 OSCAL 导出将一项控制标记为 satisfied,但操作证据随后发生变化,会发生什么情况?

OSCAL 导出在封存时将产品状态映射到 OSCAL 检测状态枚举。只有在该时刻有实时操作证据支持的控制才会接收 OSCAL satisfied。状态为 by_design、partial、gap 或 unmapped 的控制会被映射到 OSCAL not-satisfied,并且在原因字段和自定义属性中保留精确的产品状态。每个封存的证据包都是不可变的,并带有时间戳。如果证据发生变化,将封存一个反映当前状态的新包。旧的包保持完整且可重新验证,从而创建了合规状态的时间序列,而不是单一可覆盖的断言。

查看您的智能体能触及哪些资源

Olivares AI 是面向您 AI 资产体系的开放式自托管平台。将其部署在您自己的基础设施上,即可获得安全与平台团队一直期待的访问关系图。