工程2026年7月30日8 分钟阅读

可检测篡改的 Webhook 日志:哈希链与公开锚点

存在可修改数据库里的 Webhook 日志只是一种说法,不是证据。AnyHook 的事件日志如何变得可验证:按 App 划分的哈希链、每日封存、公开锚点。

每一层都折叠它下面的那一层叶节点 1叶节点 2叶节点 3叶节点 4App 根哈希每天一个锚点公开仓库我们不持有写入凭证修改或删除任何历史事件,之后的每个根哈希都会改变局限,直说:当天尚未封存的部分被篡改,无法检测
一份服务方无法悄悄改写的日志,锚定在一个它不持有任何凭据的地方。

每个 Webhook 服务都会给你看投递日志。让人不安的是,这份日志只是某个 Postgres 里的一行,任何掌握数据库的人,包括供应商自己,都可以 UPDATE 它。改一个请求体,把时间戳往前改,或者干脆删掉一整行,都不会留下任何痕迹。当一场纠纷取决于“Stripe 在 14:32 到底给你发了什么”时,一份可修改的日志不是证据。它只是一种说法,背后只有对供应商的信任。

审计框架明确说过:可能被篡改过的日志,证据价值会打折扣。所以我们让 AnyHook 的事件日志可检测篡改。本文介绍完整的设计,包括比预想中更难的部分,以及我们有意接受的上限。

TL;DR

  • 每个事件在写入时记录下其明文请求体的 SHA-256
  • 每日任务把每个 App 的事件折叠成一条链:修改或删除任何一个过去的事件,之后的每一个根都会改变
  • 每天各个 App 的根再折叠成一个公开锚点,接在前一天的锚点之后
  • 锚点由一个工作流拉取到一个公开的 GitHub 仓库中,而 AnyHook 服务不持有这个工作流的任何凭据。被攻破的服务器碰不到这份记录,改写它的历史也会被任何持有克隆的人看到
  • 直说上限:在当天尚未封存的时段内发生的篡改,无法被检测。升级路径是有的;只是还没有人需要

哈希链

三层,全都是普通的 SHA-256。

叶子,每个事件一个:

leaf = sha256("{id}|{created_at}|{body_hash}")

body_hash 在加密之前根据明文请求体计算。这是刻意的设计:保存的请求体用 AES-256-GCM 加密,并带有随机 IV,所以密文不可复现。对明文计算哈希,意味着任何持有自己收到的请求体的人,都能重新算出这片叶子。验证永远不需要我们的加密密钥。

App 根是对这个 App 当天事件的折叠,按 (created_at, id) 排序,以该 App 的上一个根作为初始值:

h = prev_root            # "" at genesis
for leaf in leaves:
    h = sha256(h + "|" + leaf)

修改事件 #5,会改变它之后的每一个哈希;删除一个,折叠就变短;插入一个倒填日期的,折叠就会错位。这三种情况都会产生不同的根。

日锚点折叠所有 (app_id, root) 对,按 App ID 排序,初始值为:

h = sha256(prev_anchor + "|" + day)

初始值里的这个 day 比看上去更重要。我们的第一个版本只用 prev_anchor 作为初始值,这意味着没有任何事件的一天,会产生和前一天完全相同的锚点,于是“连续两天没有动静”和“有一天被悄悄从时间线上删掉”无法区分。把日期折叠进去,每天的锚点就各不相同,缺了一天本身就是证据。

为什么按 App 建链,而不是一条全局链

一条全局链会更简单。但这也意味着,验证你的事件需要其他所有租户的叶子,这会把他们的事件 ID 和时间戳泄露给你。按 App 建链,让客户可以独立验证自己的历史,而日锚点只需要 (app_id, root) 对,这些数据本身不透露任何东西。

为什么封存每天执行一次,并且不在写入路径上

最直接的设计是在插入时计算链。我们没有这样做,因为全局哈希链会让写入串行化(每次插入都必须等上一次的哈希算完),而接收路径恰恰是 Webhook 转发服务最承受不起瓶颈的地方。

取而代之的是,一个定时任务对前一个 UTC 日封存一次。封存之后永不重算:一旦保留期清除了旧事件,重新折叠就会得到不同的根,而悄悄替换一个已公开的锚点,恰恰是这个系统要抓住的篡改。

代价是一个时间窗口。当天封存之前做的修改无法被检测。对于以周或月计的纠纷证据来说,24 小时的封存窗口不构成问题。如果哪天有客户需要同一小时内的保证,升级方案是按 App 的实时链:各个 App 独立串行,所以彼此之间仍然并行写入。我们写下了标明这个上限的注释,然后发布了简单的版本。

锚点是拉取的,不是推送的

仅有这条链证明不了任何事,因为掌控数据库的人可以把整条链重算一遍。锚点必须放在我们的服务器写不进去的地方,一旦有事后修改就会暴露。我们的初稿让封存任务用一个保存好的 token 把每个锚点推送到 GitHub。后来我们把方向反了过来。

锚点仓库运行自己的定时 GitHub Action,它抓取我们的公开 API,并提交它看到的内容。AnyHook 服务不持有这个仓库的任何凭据。这个工作流还拒绝改写历史:它每次运行都会重新抓取完整的锚点窗口,一旦某个之前提交过的锚点和 API 现在返回的不一致,任务就会明确地报错失败,而不是把它覆盖掉。

服务写不进去的锚点,价值严格高于它写得进去的锚点。把流程反过来,还顺带删掉了三十行处理 token 的代码,这种简化是可以一直留着的。

第三方核验这一说法需要什么请求体你收到的那份sha256叶节点折叠根哈希锚点body_hash 在加密之前计算,所以这里的每一步都不需要我们的密钥shasum 加一个 git clone,就是全部工具链
验证永远不需要我们持有的任何东西。这正是对明文请求体计算哈希的意义所在。

只用一个 shell 来验证

上面的一切都可以通过公开 API 复现。手工检查创世日:

$ printf '%s' "|2026-07-27" | shasum -a 256
f27f9546bc0e021a051d8c469539406b50dded3d21a36a9331095632852413d3

这就是 2026-07-27 已公开的锚点(当天没有事件,创世值为空,所以整个折叠就是对初始值算一次哈希)。第二天把这个值折叠进去,之后每天都是如此。GET /api/v1/anchors?day=YYYY-MM-DD 返回 (app_id, root) 对,任何审计者都可以据此重算某一天,不需要账号、密钥,也不需要我们配合。

实话实说:局限

  • 当天尚未封存,上文已经说过。这是写入路径不串行化的代价
  • 保留期限制了单个事件的可证明性。一旦事件按其套餐的保留期被清除,它的叶子就无法重算。当天的根仍然固定了事件的数量和顺序,但要证明某个具体的请求体,这个事件必须还在。更长的保留期,实实在在地买来了更长的可证明期
  • 这些承诺从功能上线那天开始,但有一个缺口:封存的定时任务直到 2026-08-01 才能通过认证,所以 07-29 到 07-31 从未封存。公开的链从 07-28 直接跳到 08-01;现在再去封存那几天,固定下来的就是数据库今天的内容,而这正是封存要排除的情况。在有请求体哈希之前写入的事件,只按 ID 和时间戳固定。一条链无法回溯覆盖它当时没有记录的历史
  • 锚点仓库放在创始人的 GitHub 账号下。服务写不进去;仓库的所有者可以。让悄悄改写代价高昂的是:强制推送过的历史,会在任何持有克隆的人那里暴露出来,而且一旦已提交的锚点发生变化,工作流就会失败

这一切都不需要区块链。Git 本身就是一条哈希链,GitHub 已经给提交打上了时间戳,而证书透明度(Certificate Transparency)多年前就验证了这种模式。这里唯一的新决定,是让书写公开记录的那支笔不在服务自己手里。

全部文章2026年7月30日 · 8 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。