Kỹ thuật30 tháng 7, 20268 phút đọc

Nhật ký webhook tamper-evident: chuỗi băm và một điểm neo công khai

Nhật ký webhook trong database sửa được là lời khẳng định, chưa phải bằng chứng. AnyHook dùng chuỗi băm theo App, niêm phong hằng ngày, điểm neo công khai.

MỖI TẦNG GỘP TẦNG BÊN DƯỚI NÓnút lá 1nút lá 2nút lá 3nút lá 4gốc của Appmỗi ngày một gốcđiểm neorepo công khaichúng tôi không giữ quyền ghisửa hoặc xóa bất kỳ sự kiện cũ nào, mọi gốc sau nó đều thay đổigiới hạn, nói thẳng: chỉnh sửa trong ngày hiện tại, khi chưa niêm phong, thì không phát hiện được
Một nhật ký mà chính dịch vụ không thể lặng lẽ viết lại, được neo ở nơi dịch vụ không có thông tin xác thực nào.

Dịch vụ webhook nào cũng cho bạn xem nhật ký lượt gửi. Điều khó chịu là nhật ký đó chỉ là một dòng trong Postgres của ai đó, và bất kỳ ai nắm database, kể cả nhà cung cấp dịch vụ, đều có thể UPDATE nó. Sửa một payload, lùi ngày một dấu thời gian (timestamp), hoặc xóa hẳn một dòng, và không để lại dấu vết nào. Khi một tranh chấp xoay quanh câu “chính xác thì Stripe đã gửi gì cho bạn lúc 14:32”, một nhật ký sửa được không phải là bằng chứng. Đó là một lời khẳng định, chỉ dựa trên niềm tin vào nhà cung cấp.

Các khung kiểm toán (audit framework) nói thẳng điều này: log có thể đã bị can thiệp thì giá trị làm bằng chứng bị giảm. Vì vậy chúng tôi làm cho nhật ký sự kiện của AnyHook trở nên tamper-evident (mọi chỉnh sửa đều để lại dấu vết). Bài này trình bày toàn bộ thiết kế, kể cả những phần khó hơn dự kiến và giới hạn mà chúng tôi chủ động chấp nhận.

TL;DR

  • Mỗi sự kiện cam kết SHA-256 của body plaintext ngay lúc được ghi
  • Một job hằng ngày gộp (fold) các sự kiện của từng App (một URL nhận cho một nguồn webhook, ví dụ “Stripe production”) thành một chuỗi: sửa hay xóa bất kỳ sự kiện nào trong quá khứ thì mọi root sau đó đều thay đổi
  • Các root của từng App trong ngày được gộp thành một điểm neo (anchor) công khai, nối tiếp vào điểm neo trước đó
  • Các điểm neo được kéo về một repository GitHub công khai bởi một workflow mà dịch vụ AnyHook không có thông tin xác thực nào. Một server bị xâm nhập không động được vào bản ghi đó, và viết lại lịch sử của nó sẽ lộ ra với bất kỳ ai giữ một bản clone
  • Giới hạn, nói thẳng: can thiệp trong ngày hiện tại, khi chưa được niêm phong, là không phát hiện được. Đã có hướng nâng cấp; chỉ là chưa ai cần đến

Chuỗi băm

Ba lớp, tất cả đều là SHA-256 thuần.

Một leaf cho mỗi sự kiện:

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

body_hash được tính từ body plaintext trước khi mã hóa. Đó là chủ đích: body được lưu đã mã hóa AES-256-GCM với IV ngẫu nhiên, nên ciphertext không tái tạo lại được. Băm plaintext nghĩa là bất kỳ ai giữ payload mà họ đã được gửi đều tính lại được leaf. Việc xác minh không bao giờ cần khóa mã hóa của chúng tôi.

Root của App là một phép fold qua các sự kiện trong ngày của App đó, sắp theo (created_at, id), với giá trị khởi đầu (seed) là root trước đó của chính App ấy:

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

Sửa sự kiện #5 sẽ đổi mọi hash phía sau nó; xóa một sự kiện làm phép fold ngắn lại; chèn một sự kiện lùi ngày làm nó lệch đi. Cả ba đều cho ra một root khác.

Điểm neo của ngày gộp mọi cặp (app_id, root), sắp theo app id, với seed là:

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

Giá trị day trong seed quan trọng hơn vẻ ngoài của nó. Phiên bản đầu tiên của chúng tôi chỉ dùng prev_anchor làm seed, nghĩa là một ngày không có sự kiện nào sẽ cho ra điểm neo giống hệt ngày trước đó, và “hai ngày yên ắng” không thể phân biệt với “một ngày bị lặng lẽ xóa khỏi dòng thời gian”. Gộp cả ngày tháng vào khiến điểm neo của mỗi ngày đều khác nhau, nên chính một khoảng trống cũng là bằng chứng.

Vì sao dùng chuỗi theo từng App, không phải một chuỗi toàn cục

Một chuỗi toàn cục sẽ đơn giản hơn. Nhưng nó cũng có nghĩa là muốn xác minh sự kiện của bạn thì cần đến leaf của mọi tenant khác, và như vậy làm lộ event ID cùng dấu thời gian của họ cho bạn. Chuỗi theo từng App cho phép một khách hàng tự xác minh lịch sử của riêng mình, còn điểm neo của ngày chỉ cần các cặp (app_id, root), vốn không trao thêm gì cho ai.

Vì sao niêm phong chạy hằng ngày, tách khỏi đường ghi

Thiết kế hiển nhiên là tính chuỗi ngay lúc insert. Chúng tôi không làm vậy, vì một chuỗi băm toàn cục buộc các lần ghi phải tuần tự (mỗi lần insert phải chờ hash của lần trước), và đường nhận sự kiện chính là chỗ một dịch vụ trung chuyển webhook (webhook relay) không thể chịu nổi một điểm nghẽn.

Thay vào đó, một cron niêm phong ngày UTC hôm trước, một lần duy nhất. Nó không bao giờ được tính lại: một khi sự kiện cũ bị xóa theo thời gian lưu trữ, fold lại sẽ cho ra root khác, và lặng lẽ thay một điểm neo đã công bố chính là kiểu can thiệp mà hệ thống này sinh ra để phát hiện.

Cái giá là một khoảng hở. Các chỉnh sửa trong ngày hiện tại, trước khi niêm phong, là không phát hiện được. Với bằng chứng tranh chấp tính bằng tuần hay tháng, khoảng niêm phong 24 giờ không đáng kể. Nếu có khách hàng cần đảm bảo ngay trong cùng một giờ, hướng nâng cấp là chuỗi trực tiếp theo từng App: mỗi App tuần tự hóa độc lập, nên các App vẫn ghi song song. Chúng tôi viết một comment ghi rõ giới hạn đó và ra mắt phiên bản đơn giản.

Điểm neo được kéo về, không phải đẩy đi

Riêng chuỗi băm không chứng minh được gì, vì ai kiểm soát database cũng tính lại được toàn bộ. Điểm neo phải nằm ở nơi server của chúng tôi không ghi được, nơi một chỉnh sửa hồi tố sẽ lộ ra. Bản nháp đầu tiên của chúng tôi để job niêm phong đẩy từng điểm neo lên GitHub bằng một token lưu sẵn. Sau đó chúng tôi đảo ngược nó.

Repository anchors chạy GitHub Action định kỳ của riêng nó, gọi API công khai của chúng tôi và commit những gì nó thấy. Dịch vụ AnyHook không có thông tin xác thực nào cho repository đó. Workflow cũng từ chối viết lại lịch sử: mỗi lần chạy, nó tải lại toàn bộ khoảng điểm neo, và nếu một điểm neo đã commit trước đó khác với những gì API trả về bây giờ, job sẽ thất bại ầm ĩ thay vì commit đè lên.

Một điểm neo mà dịch vụ không ghi được chắc chắn có giá trị hơn một điểm neo nó ghi được. Đảo ngược luồng còn xóa được ba mươi dòng code xử lý token, loại đơn giản hóa đáng giữ lại.

BÊN THỨ BA CẦN GÌ ĐỂ KIỂM CHỨNG TUYÊN BỐpayloadmà bạn đã nhận đượcsha256nút lágộpgốcđiểm neobody_hash được tính trước khi mã hóa, nên không bước nào ở đây cần khóa của chúng tôishasum và một lệnh git clone là toàn bộ công cụ cần có
Việc xác minh không bao giờ cần thứ gì chúng tôi nắm giữ. Đó chính là lý do để băm body plaintext.

Xác minh chỉ với một shell

Mọi thứ ở trên đều tái tạo được từ API công khai. Ngày genesis, kiểm tra bằng tay:

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

Đó là điểm neo đã công bố cho ngày 27/07/2026 (không có sự kiện nào và genesis rỗng, nên toàn bộ phép fold chỉ là một lần băm seed). Ngày thứ hai gộp giá trị đó vào, và cứ thế tiếp tục. GET /api/v1/anchors?day=YYYY-MM-DD trả về các cặp (app_id, root) mà bất kỳ kiểm toán viên nào cũng cần để tính lại một ngày, không cần tài khoản, không cần key, cũng không cần chúng tôi hợp tác.

Giới hạn, nói thật

  • Ngày hiện tại chưa được niêm phong, như đã nói ở trên. Đó là cái giá của một đường ghi không tuần tự hóa
  • Thời gian lưu trữ giới hạn khả năng chứng minh từng sự kiện. Khi một sự kiện bị xóa theo thời gian lưu trữ của gói, leaf của nó không tính lại được nữa. Root của ngày vẫn cam kết số lượng và thứ tự, nhưng để chứng minh một payload cụ thể thì sự kiện đó phải vẫn còn. Thời gian lưu trữ dài hơn đúng nghĩa đen là mua được khả năng chứng minh lâu hơn
  • Các cam kết bắt đầu từ ngày tính năng ra mắt, với một lỗ hổng: cron của bước niêm phong không xác thực được cho tới ngày 01/08/2026, nên các ngày từ 29/07 đến 31/07 chưa bao giờ được niêm phong. Chuỗi công khai đi thẳng từ 28/07 sang 01/08, và niêm phong những ngày đó bây giờ sẽ là cam kết những gì database đang nói hôm nay, đúng thứ mà niêm phong sinh ra để loại trừ. Các sự kiện được ghi trước khi có body hash chỉ được cam kết bằng id và dấu thời gian. Một chuỗi không thể hồi tố bao phủ phần lịch sử mà nó chưa từng đo
  • Repository anchors nằm trên tài khoản GitHub của nhà sáng lập. Dịch vụ không ghi được vào đó; chủ sở hữu thì có. Điều khiến một lần viết lại lặng lẽ trở nên đắt giá là lịch sử bị force-push sẽ lộ ra với bất kỳ ai giữ bản clone, và workflow sẽ thất bại khi một điểm neo đã commit bị thay đổi

Không điều gì ở đây cần đến blockchain. Git vốn đã là một chuỗi băm, GitHub vốn đã gắn dấu thời gian cho commit, và Certificate Transparency đã chứng minh pattern này từ nhiều năm trước. Quyết định mới duy nhất ở đây là giữ cây bút ghi bản ghi công khai nằm ngoài tầm tay của dịch vụ.

Tất cả bài viết30 tháng 7, 2026 · 8 phút

Đừng để mất webhook nữa.

Đổi một URL. Có ngay cơ chế thử lại, nhật ký sự kiện và phát lại bằng một cú bấm. Hoặc xem tận mắt trước: đăng ký, chạy bài diễn tập sự cố 30 giây và xem một sự kiện vẫn còn nguyên khi server của bạn ngừng hoạt động. Không cần có server.