Setiap layanan webhook menampilkan log pengiriman. Bagian yang tidak nyaman: log itu hanyalah baris di Postgres milik seseorang, dan siapa pun yang memegang database-nya, termasuk vendornya, bisa melakukan UPDATE terhadapnya. Ubah payload, mundurkan stempel waktu (timestamp), atau hapus satu baris sepenuhnya, dan tidak ada jejaknya. Saat sengketa bergantung pada “apa persisnya yang dikirim Stripe kepada Anda pada pukul 14.32”, log yang bisa diubah bukan bukti. Log itu klaim, yang hanya didukung oleh kepercayaan pada vendor.
Kerangka audit menyatakannya terang-terangan: log yang mungkin sudah dimanipulasi punya nilai pembuktian yang lebih rendah. Jadi kami membuat log event AnyHook tamper-evident (setiap perubahan dapat terdeteksi). Artikel ini membahas desain lengkapnya, termasuk bagian yang lebih sulit dari perkiraan dan batas yang sengaja kami terima.
TL;DR
- Setiap event mencatat SHA-256 dari body plaintext-nya saat ditulis
- Job harian menggabungkan event setiap App menjadi sebuah rantai: ubah atau hapus event lama mana pun, dan setiap root sesudahnya berubah
- Root App setiap hari digabungkan menjadi satu anchor publik, yang dirantai ke anchor sebelumnya
- Anchor ditarik ke repository GitHub publik oleh workflow yang kredensialnya tidak dipegang layanan AnyHook. Server yang dibobol tidak bisa menyentuh catatan itu, dan penulisan ulang riwayatnya akan terlihat oleh siapa pun yang memegang clone-nya
- Batasnya, dinyatakan terus terang: manipulasi di dalam hari berjalan yang belum disegel tidak bisa dideteksi. Jalur peningkatannya ada; belum ada yang membutuhkannya
Rantainya
Tiga lapisan, semuanya SHA-256 biasa.
Leaf, satu per event:
leaf = sha256("{id}|{created_at}|{body_hash}")
body_hash dihitung dari body plaintext sebelum dienkripsi. Itu disengaja: body yang disimpan dienkripsi AES-256-GCM dengan IV acak, jadi ciphertext-nya tidak bisa direproduksi. Meng-hash plaintext berarti siapa pun yang memegang payload yang dikirimkan kepadanya bisa menghitung ulang leaf-nya. Verifikasi tidak pernah butuh kunci enkripsi kami.
Root App adalah fold atas event App pada hari itu, diurutkan berdasarkan (created_at, id), dengan seed berupa root App tersebut sebelumnya:
h = prev_root # "" at genesis
for leaf in leaves:
h = sha256(h + "|" + leaf)
Mengedit event #5 mengubah setiap hash sesudahnya; menghapus satu event memperpendek fold; menyisipkan event bertanggal mundur menggesernya. Ketiganya menghasilkan root yang berbeda.
Anchor harian menggabungkan setiap pasangan (app_id, root), diurutkan berdasarkan app id, dengan seed:
h = sha256(prev_anchor + "|" + day)
day di dalam seed itu lebih penting daripada kelihatannya. Versi pertama kami hanya memakai prev_anchor sebagai seed, artinya hari tanpa event menghasilkan anchor yang identik dengan hari sebelumnya, dan “dua hari yang sepi” tidak bisa dibedakan dari “satu hari yang diam-diam dihapus dari linimasa”. Memasukkan tanggal ke dalam fold membuat anchor setiap hari berbeda, jadi celah itu sendiri menjadi bukti.
Mengapa rantai per App, bukan satu rantai global
Satu rantai global akan lebih sederhana. Tetapi itu juga berarti memverifikasi event Anda butuh leaf milik semua tenant lain, yang membocorkan ID event dan stempel waktu mereka kepada Anda. Rantai per App memungkinkan pelanggan memverifikasi riwayatnya sendiri secara mandiri, dan anchor harian hanya butuh pasangan (app_id, root), yang tidak memberi akses apa pun.
Mengapa penyegelan berjalan harian, di luar jalur penulisan
Desain yang paling jelas menghitung rantai saat insert. Kami tidak melakukannya, karena rantai hash global membuat penulisan berjalan serial (setiap insert harus menunggu hash dari insert sebelumnya), dan jalur ingest justru tempat relay webhook tidak boleh punya bottleneck.
Sebagai gantinya, cron menyegel hari UTC sebelumnya satu kali. Hasilnya tidak pernah dihitung ulang: begitu event lama dihapus karena masa simpannya habis, fold ulang akan menghasilkan root yang berbeda, dan mengganti anchor yang sudah dipublikasikan secara diam-diam persis manipulasi yang ingin ditangkap sistem ini.
Harganya adalah sebuah jendela waktu. Suntingan di dalam hari berjalan, sebelum penyegelan, tidak bisa dideteksi. Untuk bukti sengketa yang diukur dalam minggu atau bulan, jendela penyegelan 24 jam tidak relevan. Jika suatu saat pelanggan butuh jaminan dalam jam yang sama, peningkatannya adalah rantai live per App: setiap App berjalan serial secara terpisah, jadi penulisan antar-App tetap paralel. Kami menulis komentar yang menyebut batas itu dan merilis versi yang sederhana.
Anchor ditarik, bukan didorong
Rantai saja tidak membuktikan apa pun, karena siapa pun yang mengendalikan database bisa menghitung ulang semuanya. Anchor harus tinggal di tempat yang tidak bisa ditulisi server kami, tempat suntingan retroaktif akan terlihat. Draf pertama kami membuat job penyegelan mendorong setiap anchor ke GitHub dengan token yang disimpan. Lalu kami membaliknya.
Repository anchor menjalankan GitHub Action terjadwalnya sendiri yang mengambil data dari API publik kami dan meng-commit apa yang dilihatnya. Layanan AnyHook tidak memegang kredensial apa pun untuk repository itu. Workflow-nya juga menolak menulis ulang riwayat: setiap kali berjalan, workflow mengambil ulang seluruh jendela anchor, dan jika anchor yang sudah di-commit sebelumnya berbeda dari yang kini dikembalikan API, job gagal dengan jelas alih-alih menimpanya dengan commit baru.
Anchor yang tidak bisa ditulis oleh layanannya jelas lebih berharga daripada anchor yang bisa. Membalik alurnya juga menghapus tiga puluh baris kode pengelolaan token, jenis penyederhanaan yang layak dipertahankan.
Memverifikasi, hanya dengan shell
Semua di atas bisa direproduksi dari API publik. Hari genesis, diperiksa secara manual:
$ printf '%s' "|2026-07-27" | shasum -a 256
f27f9546bc0e021a051d8c469539406b50dded3d21a36a9331095632852413d3
Itulah anchor yang dipublikasikan untuk 2026-07-27 (nol event dan genesis kosong, jadi seluruh fold-nya adalah satu hash dari seed). Hari kedua memasukkan nilai itu ke fold-nya, dan seterusnya. GET /api/v1/anchors?day=YYYY-MM-DD mengembalikan pasangan (app_id, root) yang dibutuhkan auditor mana pun untuk menghitung ulang satu hari tanpa akun, tanpa kunci, dan tanpa kerja sama dari kami.
Batasan, secara jujur
- Hari berjalan belum disegel, seperti dibahas di atas. Itu harga dari jalur penulisan yang tidak serial
- Masa simpan membatasi pembuktian per event. Begitu event dihapus sesuai masa simpan paketnya, leaf-nya tidak bisa dihitung ulang. Root hari itu tetap mengikat jumlah dan urutannya, tetapi membuktikan payload tertentu mengharuskan event-nya masih ada. Masa simpan yang lebih panjang secara harfiah membeli masa pembuktian yang lebih panjang
- Komitmen dimulai pada hari fitur ini dirilis, dengan satu lubang: cron penyegelan tidak bisa melakukan autentikasi sampai 2026-08-01, jadi 07-29 sampai 07-31 tidak pernah disegel. Rantai publik berjalan dari 07-28 langsung ke 08-01, dan menyegel hari-hari itu sekarang berarti mengikat apa yang dikatakan database hari ini, persis hal yang ingin dicegah oleh penyegelan. Event yang ditulis sebelum body hash ada hanya diikat berdasarkan id dan stempel waktu. Rantai tidak bisa secara retroaktif mencakup riwayat yang tidak diukurnya
- Repository anchor berada di akun GitHub milik pendiri. Layanannya tidak bisa menulis ke sana; pemiliknya bisa. Yang membuat penulisan ulang diam-diam menjadi mahal adalah riwayat hasil force-push akan terlihat oleh siapa pun yang memegang clone, dan workflow gagal saat anchor yang sudah di-commit berubah
Semua ini tidak butuh blockchain. Git sendiri sudah berupa rantai hash, GitHub sudah memberi stempel waktu pada commit, dan Certificate Transparency membuktikan pola ini bertahun-tahun lalu. Satu-satunya keputusan baru di sini adalah menjauhkan pena yang menulis catatan publik dari tangan layanan.