每個 Webhook 服務都會給你看傳遞紀錄。令人不安的是,那份紀錄只是某人 Postgres 裡的一列資料,任何掌握資料庫的人,包括業者自己,都可以 UPDATE 它。改一個 payload、把時間戳記往前改,或整列刪掉,都不會留下痕跡。當爭議的關鍵在於「Stripe 在 14:32 到底送了什麼給你」時,一份可修改的紀錄算不上證據。它只是一面之詞,唯一的擔保是你對業者的信任。
稽核框架講得很明白:可能遭竄改的紀錄,證據價值會降低。所以我們讓 AnyHook 的事件紀錄可以偵測竄改。這篇涵蓋完整的設計,包括比預期更難的部分,以及我們刻意接受的上限。
重點摘要
- 每個事件寫入時,就把明文請求主體的 SHA-256 一起記下。
- 每日排程會把每個 App(一個接收網址,對應一個 Webhook 來源)的事件串成一條鏈:修改或刪除任何過去的事件,之後的每一個根雜湊都會改變。
- 每天各 App 的根雜湊再合成一個公開錨點,串接在前一天的錨點之後。
- 錨點由一個 workflow 拉進公開的 GitHub 儲存庫,而 AnyHook 服務沒有那個儲存庫的任何憑證。伺服器就算被入侵,也碰不到這份紀錄;改寫它的歷史,任何持有 clone 的人都會看得出來。
- 上限,直說:在當天尚未封緘的期間內竄改,是偵測不到的。升級路徑已經有了,只是還沒有人需要。
這條鏈
三層,全都是單純的 SHA-256。
葉節點,每個事件一個:
leaf = sha256("{id}|{created_at}|{body_hash}")
body_hash 是在加密之前,從明文主體計算出來的。這是刻意的:儲存的主體以 AES-256-GCM 搭配隨機 IV 加密,所以密文無法重現。對明文計算雜湊,代表任何持有自己收到的 payload 的人,都能重新算出葉節點。驗證永遠不需要我們的加密金鑰。
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 中繼服務(webhook relay)最承受不起瓶頸的地方。
取而代之的是一個 cron,每天把前一個 UTC 日封緘一次。封緘永遠不會重算:保存期限一到、舊事件被清除後,重新計算就會得出不同的根雜湊,而悄悄替換一個已公開的錨點,正是這個系統要抓的那種竄改。
代價是一段空窗。在當天封緘之前做的修改,是偵測不到的。對以週或月為單位的爭議證據來說,24 小時的封緘空窗無關緊要。如果哪天有客戶需要同一小時內的保證,升級方式是每個 App 各自在寫入當下串鏈:各 App 獨立序列化,所以彼此仍然能平行寫入。我們在程式裡寫下註解,點明這個上限,然後先上線簡單的版本。
錨點是拉取,不是推送
光有這條鏈什麼也證明不了,因為控制資料庫的人可以把整條鏈重算一遍。錨點必須放在我們的伺服器無法寫入、而且事後修改會留下痕跡的地方。我們的初版設計,是讓封緘工作用一組儲存好的 token,把每個錨點推送到 GitHub。後來我們把方向反了過來。
錨點儲存庫會執行自己的排程 GitHub Action,抓取我們的公開 API,再把看到的內容 commit 進去。AnyHook 服務沒有那個儲存庫的任何憑證。這個 workflow 也拒絕改寫歷史:每次執行都會重新抓取完整的錨點區間,只要之前已 commit 的錨點和 API 現在回傳的不一樣,工作就會大聲失敗,而不是直接覆蓋過去。
服務無法寫入的錨點,價值一定高於服務能寫入的錨點。把流程反過來,也順便刪掉了三十行處理 token 的程式碼,這是那種可以一直留著的簡化。
只靠一個 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) 組合,不需要帳號、金鑰,也不需要我們配合。
誠實列出的限制
- 如前所述,當天尚未封緘。這是寫入路徑不序列化的代價。
- 保存期限限制了單一事件的證明。事件依方案的保存期限被清除後,它的葉節點就無法重算。當天的根雜湊仍然綁定了事件數量和順序,但要證明某個特定 payload,前提是事件還存在。更長的保存期限,就是更長的可證明期間。
- 綁定從這個功能上線那天開始,但有一個缺口:封緘的 cron 直到 2026-08-01 才能通過驗證,所以 07-29 到 07-31 從來沒有封緘。公開的鏈從 07-28 直接接到 08-01,而現在才封緘那幾天,綁定的會是資料庫今天的內容,這正是封緘存在所要排除的事。在主體雜湊出現之前寫入的事件,只以 ID 和時間戳記綁定。一條鏈無法回溯涵蓋它當時沒有在衡量的歷史。
- 錨點儲存庫放在創辦人的 GitHub 帳號下。服務寫不進去,但它的擁有者可以。讓悄悄改寫變得代價高昂的,是 force push 過的歷史,任何持有 clone 的人都看得到,而且已 commit 的錨點一有變動,workflow 就會失敗。
這一切都不需要區塊鏈。Git 本身就是一條雜湊鏈,GitHub 本來就會替 commit 打上時間戳記,Certificate Transparency 多年前就證明了這個模式。這裡唯一新穎的決定,是讓寫公開紀錄的那支筆,不在服務自己手上。