# 加密审计日志验证能发现删除吗？

哈希链可以发现审计文件中间缺少记录，但无法可靠地告诉你有人是否从末尾截掉了记录。这一区别常被团队忽略，直到事故发生时才发现：他们保留了完整性证据，却没有保留完整历史。

加密不会改变这个结论。它保护记录内容。对加密字节建立的链，可以证明这些字节仍按预期连接，即使运行验证器的人都无法解密它们。但除非文件之外还有证据记住了历史中的更晚位置，否则它无法证明文件包含曾经存在的每条记录。

NIST 将哈希链描述为一种只追加结构，每个区块都包含前一份数据的哈希，因此修改某个区块会改变后继区块中记录的摘要。这只能证明存在篡改迹象，并不能让人看到已经消失的历史。

## 链验证检查的是关系，而不是完整性

验证器会检查每条记录是否指向正确的前驱，以及每条记录的摘要是否与保存的字节匹配。给定受信任的第一条记录后，它可以确认从该记录开始、直到所有现存记录的连续性。

假设文件包含第 1 到第 100 条记录。第 58 条包含第 57 条的摘要，第 59 条包含第 58 条的摘要。如果有人删除第 58 条而保持其他内容不变，第 59 条仍然指向验证器找不到的摘要。验证会在这个缺口处失败。

现在删除第 91 到第 100 条。第 90 条仍然正确地指向第 89 条。缩短后的文件内部没有任何信息说明第 90 条之后原本还有十条记录。一个从第 1 条开始、读到文件末尾就停止的验证器可能返回成功。它验证了文件呈现的历史，却没有验证这段历史是否完整。

人们常把三个不同的结论都压缩成「日志不可篡改」：

- 现存记录没有被修改。
- 没有记录从中间被删除。
- 文件结束的位置就是原始历史结束的位置。

这三个结论需要不同的证据。线性链很适合证明第一点，也能发现简单的中间删除。第三点需要独立的后续承诺，通常称为检查点、日志头、封存值、收据或见证值。

Certificate Transparency 通过另一种数据结构表达了同样的区别。RFC 9162 说明，Merkle 一致性证明可以证明较新的树相对于此前公布的树只进行了追加。这里，先前公布的树头发挥了关键作用。没有它，当前根哈希无法告诉你日志运营者决定不展示哪些记录。

## 建立把载荷视为不透明密文的测试日志

不要在生产审计文件上练习。创建一个一次性测试装置，让载荷对验证器来说像密文，然后针对精确的字节记录测试删除操作。

下面的脚本写入一个 JSON Lines 文件。每条记录包含序列号、不透明的 base64 载荷、前驱摘要和自身摘要。载荷是随机测试数据，并不是真正的加密。对于这个测试已经足够，因为链验证器只需要稳定的不透明字节。在真实的加密日志中，应替换为实际序列化的密文记录及其认证元数据。

```python
# make_log.py
import base64
import hashlib
import json
import os

OUT = "audit.jsonl"
ZERO = "0" * 64


def canonical(record):
    return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()


def digest(record):
    return hashlib.sha256(canonical(record)).hexdigest()

prev = ZERO
with open(OUT, "w", encoding="utf-8") as f:
    for seq in range(1, 13):
        body = {
            "seq": seq,
            "ciphertext": base64.b64encode(os.urandom(24)).decode(),
            "prev": prev,
        }
        body["hash"] = digest(body)
        f.write(json.dumps(body, sort_keys=True) + "\n")
        prev = body["hash"]

print(f"wrote 12 records to {OUT}")
print(f"head seq=12 hash={prev}")
```

应使用单独的验证器，而不是让写入程序自行验证输出。验证器应拒绝重复序列号、意外的序列跳跃、格式错误的记录、错误的前驱引用和错误的摘要。只检查哈希的验证器存在盲点，因为攻击者如果同时调整了足够多的关联字段，可能让重排后的文件通过验证。

```python
# verify_log.py
import hashlib
import json
import sys

ZERO = "0" * 64


def canonical(record):
    return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()


def digest_without_hash(record):
    copy = dict(record)
    supplied = copy.pop("hash", None)
    return supplied, hashlib.sha256(canonical(copy)).hexdigest()


def fail(message):
    print(f"FAIL {message}")
    raise SystemExit(1)

path = sys.argv[1]
prev = ZERO
expected_seq = 1
last_hash = ZERO

with open(path, encoding="utf-8") as f:
    for line_no, line in enumerate(f, start=1):
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            fail(f"line={line_no} invalid JSON")

        if record.get("seq") != expected_seq:
            fail(f"line={line_no} expected_seq={expected_seq} got={record.get('seq')}")

        if record.get("prev") != prev:
            fail(f"line={line_no} seq={expected_seq} predecessor mismatch")

        supplied, calculated = digest_without_hash(record)
        if supplied != calculated:
            fail(f"line={line_no} seq={expected_seq} digest mismatch")

        prev = supplied
        last_hash = supplied
        expected_seq += 1

print(f"OK records={expected_seq - 1} head={last_hash}")
```

运行测试装置，并把报告的日志头保存在日志文件之外：

```text
python3 make_log.py
python3 verify_log.py audit.jsonl
```

成功时应类似于下面这样，每次运行都会得到不同的摘要：

```text
wrote 12 records to audit.jsonl
head seq=12 hash=8f...c2
OK records=12 head=8f...c2
```

最后的 `records=12` 和 `head=...` 组合就是你的检查点。在修改副本前，把它写入单独的测试记录文件。如果检查点只留在准备攻击的文件中，你实际上只是建立了一份攻击者可以修改的记录。

## 删除末尾记录会立即显示限制

末尾删除测试应让所有人谨慎使用「完整」这个词。复制文件，删除最后三行，然后运行同一个验证器。

```text
cp audit.jsonl tail-cut.jsonl
head -n 9 audit.jsonl > tail-cut.jsonl
python3 verify_log.py tail-cut.jsonl
```

验证器会报告九条记录验证成功。这正是应有的结果。第 1 到第 9 条仍然组成有效链。如果把这种结果称为验证器失败，就会促成一种危险设计，让有效但不完整的历史看起来像损坏文件。

将结果与检查点比较：

```text
OK records=9 head=4a...91
expected records=12 head=8f...c2
```

现在你有了截断证据。证明来自文件变短后与文件较长时保存的外部证据不一致，而不是来自缩短文件内部的链接。

序列号有助于人工诊断，但它本身不会产生安全性。能够改写末尾的攻击者也能把最终序列号从 12 改成 9。只有当预期值来自攻击者无法改写的来源时，序列号才真正有用，例如由独立服务保存的签名检查点、受保护的发布构件，或发送到另一个管理域的导出文件。

许多审计设计会跳过这个看似过于明显的测试。但在破坏性操作之后，这正是关键失败模式。恶意进程不需要让历史在内部出现矛盾，只需让历史提前停止，就能隐藏某个操作。

## 中间删除应当失败，但要明确前提

再创建一个副本，删除一条同时拥有前驱和后继的记录。第 6 条是合适的目标。

```text
cp audit.jsonl middle-cut.jsonl
sed '6d' audit.jsonl > middle-cut.jsonl
python3 verify_log.py middle-cut.jsonl
```

结果应类似于：

```text
FAIL line=6 expected_seq=6 got=7
```

如果去掉序列检查，验证器会在下一项测试中失败，因为第 7 条仍携带第 6 条的摘要，而验证器手中只有第 5 条的摘要。两项检查都应保留。序列不匹配能让操作人员一眼看出问题，前驱不匹配则说明断裂的加密关系。

不要声称哈希链总能检测中间删除。当攻击者无法改写剩余链时，它能检测简单删除。公开的摘要算法允许任何人计算新哈希。如果某人可以删除第 6 条，修改第 7 条的前驱字段，重新计算第 7 条的哈希，再一路处理到第 12 条，那么重建后的文件可以使用自己的新日志头通过验证。

这不是碰撞攻击，也不是 SHA-256 被破解，而是普通的重新计算。系统接受了新的历史，因为没有受保护的证据证明旧历史曾经存在。

NIST 关于数字证据的指导明确指出，保存的哈希必须位于证据访问者无法修改或覆盖的位置。该报告也提到哈希链有助于保护哈希，但参考值仍然必须存放在外部。

因此，中间删除测试应有两个版本：

1. 删除一行但保持后续字节不变。链必须失败。
2. 删除一行并重新生成所有后续摘要。重建后的链必须与独立保留的检查点比对失败。

如果测试计划只运行第一种，它测试的是文件损坏和粗心篡改，并没有测试能够写入日志存储的攻击者。

## 加密与链完整性回答的是不同问题

加密记录带来了清晰的分工。操作人员可以在不读取秘密、请求正文、命令输出或其他敏感审计内容的情况下验证密文链。拥有适当权限的调查人员之后可以解密相关记录。对于审计数据本身很敏感的场景，这是合理的设计。

但加密字节不会证明自己的来源。密文记录可以被删除。控制加密和日志路径的人也可以添加新的密文记录。如果加密模式不认证数据，密文有时还能被改成乱码，而解密者不知道原因。应使用认证加密来保证记录的保密性和完整性，再使用哈希链把记录绑定成有序历史。

在设计文档和事故报告中，应分开描述这些检查：

- 记录认证检查某条加密记录是否被修改。
- 链验证检查每条现存记录是否跟随声称的前驱。
- 检查点比较检查观察到的历史是否到达此前观察到的日志头。
- 事件捕获检查系统是否根本把操作写入日志。

最后一个问题最棘手。如果被入侵的写入程序选择不发出记录，任何加密日志都无法证明某个事件曾被记录。可以从独立边界收集证据来降低风险，例如网络服务、主机审计设施或人工批准事件。但不能仅仅把本地日志称为只追加，就让这个缺口消失。

Sallyport 的审计模型在这里很有参考价值：代理运行日志和单次调用日志都来自同一份加密哈希链审计日志，`sp audit verify` 可以在没有保险库密钥的情况下离线验证密文链。这样，审核人员可以检查连续性，而不会暴露审计轨迹所保护的凭据或操作数据。

## 检查点必须难以改写，而不只是被复制

检查点是在某个时间点对日志作出的声明。至少应包括日志标识、最终序列号、最终摘要、创建时间，以及格式或算法版本。应把它作为独立构件保存，并建立单独的保管责任。

下面这个小格式足够用于测试工具：

```json
{
  "log_id": "agent-actions-test-a",
  "sequence": 12,
  "head": "8f...c2",
  "created_at": "2026-07-22T14:30:00Z",
  "format": "jsonl-chain-v1"
}
```

不要单独信任 `created_at` 字段。本地时钟适合排序和调查，但控制机器的攻击者也可能控制时钟和保存该字段的文件。检查点的力量来自它被发送到哪里，以及谁能够修改它。

一种实用安排是由一方写入审计记录，另一方定期保存日志头。第二方不需要解密权限，只需要足够的信息，能够拒绝最终摘要和序列号与此前观察结果不一致的文件。

对于开发者工具，独立方可以很简单，例如受保护的 CI 构件、发布证明系统、单独的采集器账户，或由另一名管理员批准的每日导出。对于风险更高的操作，应在操作完成后立即发出检查点，并把它保存在工作站之外。频率应与可接受的损害窗口相匹配。每天一个检查点无法证明从昨天的日志头到今天缺失的末尾之间那段时间的完整性。

为检查点签名有助于让验证者确认发布者身份。签名把检查点数据绑定到签名方，但不能保证签名方诚实。如果同一个被入侵的进程既写审计日志又签署替代检查点，你仍然只有一个信任边界。应把见证者放到原始写入者无法悄悄控制的位置。

## 只写存储会改变你测试的攻击者

存放在写入者可以自由修改旧记录的位置上的链，与通过只写追加路径存储的链，面对的是不同威胁模型。第一种设计需要外部见证者来发现历史被重写。第二种设计则试图从源头阻止写入者重写历史。

要准确理解「只写」。它应表示，提交新记录的组件不能随意读取或修改之前的加密日志记录。这不代表磁盘不会故障、管理员不能删除文件，也不代表操作系统不会被入侵。它只缩小了一条攻击路径，即攻击者在查看历史后重写其中一部分。

这种区别会影响测试用例。对于普通文件访问，应测试删除并重新计算哈希，因为攻击者可以读取数据并计算摘要。对于只写存储，应测试调用者能否请求删除、替换、回滚，或以同一身份创建第二份日志。还要测试验证器如何识别目标日志。来自错误文件的完美链仍然是错误证据。

这里最常见的错误，是把存储权限当成加密锚点。权限可以被拥有足够权限的攻击者更改。权限仍然有价值，因为它能减少普通损坏并限制可操作的进程，但它不能替代跨越攻击者无法控制的边界的检查点。

## 单独测试回滚与截断

回滚看起来像保留一份可信的旧文件后进行末尾截断。机器可能在崩溃、恢复备份或遭到故意替换后，恢复到昨天的有效审计文件。该文件昨天确实有效，所以可以通过内部验证。

如果检查点保存了更新的日志头，就能发现这种情况。应把候选文件与最新保留的检查点比较，而不是与恰好找到的最早检查点比较。链验证器无法自行推断哪个有效版本应当是当前版本。

直接测试：

1. 保存 `audit.jsonl` 及其 12 条记录的检查点。
2. 生成包含更多记录的另一个测试文件，并保存更新的检查点。
3. 用 12 条记录的副本替换更新后的文件。
4. 先在内部验证恢复的文件，再与更新后的检查点比较。

应得到两个不同的结果。内部验证应成功。检查点比较应失败，因为文件停留在更早的序列号和摘要。如果两个检查都失败，可能是测试文件字节发生了变化，而不是回滚。如果两个检查都成功，说明你使用了错误的检查点，或者根本没有保留检查点。

不要通过向旧文件追加新记录来「修复」回滚。应先保留现场。追加之后，你会把可能缺失的历史与事故后的活动混在一起。应在记录事故边界后开始新的日志段，或遵循组织已经批准的保留和恢复流程。

## 让验证器结果在调查中真正有用

只返回 `invalid` 的验证器会制造不必要的工作。它应指出第一个失败条件，但不要打印敏感的记录载荷。序列号、字节偏移量或行号、预期前驱摘要、观察到的前驱摘要，以及计算出的记录摘要，通常已经足够。

不要在错误消息中记录解密后的内容。验证工具可能在 CI、支持数据包或终端记录中运行。如果加密审计设计在验证失败时泄露明文，就会把完整性事故变成数据泄露。

处理验证失败时，应按以下顺序操作：

- 保留原始文件，并记录该副本的摘要。
- 收集最新检查点、此前检查点和所有外部导出文件。
- 在副本上运行验证，并保存完整的命令输出。
- 将报告的日志头与所有保留的检查点比较。
- 判断失败属于损坏、中间缺口、末尾丢失、回滚，还是与见证值冲突的重写链。

分类很重要。中间缺口说明文件内部存在矛盾。有效但更短的链说明文件最多只能在最终记录处完整。有效链与更晚的检查点不一致，说明你有回滚、截断或替换的证据。重写后的链内部可能看起来很干净，但会与较早的独立日志头不一致。

不要作出超过证据支持范围的承诺。「审计链已验证到序列 90，但与保留的序列 100 检查点不一致」是有力且具体的结论。「没有人修改日志」则不是。

## 真正重要的测试，是针对实际边界的测试

先运行一次性文件删除测试，因为它们能说明机制。然后在真实审计导出文件和真实验证器上运行相同案例，使用副本，并遵守格式规定的可操作边界。不要为了看看结果就在生产加密日志上直接编辑。

对于 Sallyport，应保留原始加密日志，在每次受控修改前后都对副本运行 `sp audit verify`。记录验证器是否发现内部缺口、缩短后的副本是否仍然验证成功，以及保留的日志头是否暴露了较短的历史。三者的答案比笼统地说日志具有防篡改性更有价值。

有效链意味着所检查的记录彼此一致。有效链加上受信任的后续检查点，则意味着记录到达了历史中的一个已知位置。当缺失操作会造成影响时，应围绕第二个结论来构建和测试。
