エンジニアリング2026年7月30日8 分で読めます

改ざん検知可能な Webhook ログ:ハッシュチェーンと公開アンカー

変更できるデータベースの Webhook ログは、証拠ではなく主張です。AnyHook はハッシュチェーン、毎日の封印、公開アンカーでログを検証可能にしました。

各層が 1 つ下の層を畳み込むリーフ 1リーフ 2リーフ 3リーフ 4App ルート1 日に 1 つアンカー公開リポジトリAnyHook は書き込み権限を持たない過去のどのイベントを変更・削除しても、以降のルートがすべて変わる限界もはっきり書いておく:まだ封印していない当日分の改ざんは検知できない
サービス自身もこっそり書き換えられないログ。そのアンカーは、サービスが認証情報を持たない場所にある。

どの Webhook サービスも、配信ログを見せてくれます。居心地が悪いのは、そのログが誰かの Postgres の行にすぎず、データベースを握っている人なら、ベンダー自身も含めて誰でも UPDATE できることです。ペイロードを変えても、タイムスタンプを過去の日時に書き換えても、行をまるごと削除しても、痕跡は残りません。「14:32 に Stripe から正確に何が届いたのか」が争点になったとき、書き換えられるログは証拠になりません。ベンダーへの信頼以外に何の裏付けもない、ただの主張です。

監査のフレームワークも、改ざんされた可能性のあるログは証拠としての価値が下がると、はっきり述べています。そこで、AnyHook のイベントログを改ざん検知可能にしました。この記事では、想定より難しかった部分と、意図的に受け入れた上限も含めて、設計の全体を説明します。

TL;DR

  • すべてのイベントは、書き込みの時点で、平文のボディの SHA-256 をコミットします
  • 毎日のジョブが、App(Webhook の送信元ごとに作る受信設定)ごとのイベントをチェーンに畳み込みます。過去のどのイベントでも、変更または削除すれば、それ以降のルートがすべて変わります
  • その日の App ごとのルートは 1 つの公開アンカーに畳み込まれ、前日のアンカーにつながります
  • アンカーは、AnyHook のサービスが認証情報を持たないワークフローによって、公開の GitHub リポジトリに取り込まれます。侵害されたサーバーはその記録に触れられず、履歴を書き換えれば、クローンを持っている誰にでもわかります
  • 上限をはっきり書いておきます。まだ封印されていない当日の改ざんは、検知できません。改善の道筋はありますが、まだ誰も必要としていません

チェーン

3 つの層があり、どれもただの SHA-256 です。

リーフは、イベント 1 件につき 1 つです。

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

body_hash は、暗号化する前の平文のボディから計算します。これは意図的です。保存されるボディはランダムな IV を使って AES-256-GCM で暗号化されるので、暗号文は再現できません。平文をハッシュすれば、送られてきたペイロードを持っている人なら誰でも、リーフを再計算できます。検証に、AnyHook の暗号鍵が要ることはありません。

App のルートは、その App の 1 日分を (created_at, id) の順に畳み込んだものです。初期値には、その App の前回のルートを使います。

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

イベント #5 を編集すると、それ以降のハッシュがすべて変わります。1 件削除すると畳み込みが短くなり、日時をさかのぼったイベントを挿入すると、畳み込みがずれます。3 つのどれでも、ルートが変わります。

日次アンカーは、すべての (app_id, root) の組を App の ID 順に並べて畳み込みます。初期値は次のとおりです。

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

初期値に含めた day は、見た目以上に重要です。最初のバージョンでは、prev_anchor だけを初期値にしていました。そのため、イベントが 0 件の日は前日とまったく同じアンカーを生み、「静かな 2 日間」と「タイムラインからこっそり消された 1 日」を区別できませんでした。日付を畳み込めば、どの日のアンカーも互いに異なるので、欠落そのものが証拠になります。

グローバルな 1 本のチェーンではなく、App ごとのチェーンにした理由

グローバルな 1 本のチェーンのほうが単純です。しかしその場合、自分のイベントを検証するのに、ほかのすべてのテナントのリーフが必要になり、そのイベント ID とタイムスタンプが漏れてしまいます。App ごとのチェーンなら、顧客は自分の履歴を単独で検証できます。日次アンカーに必要なのは (app_id, root) の組だけで、それを知っても何の権限も得られません。

封印を毎日、書き込み経路の外で行う理由

素直な設計なら、挿入のたびにチェーンを計算します。AnyHook がそうしなかったのは、グローバルなハッシュチェーンでは書き込みが直列化され(どの挿入も、直前の挿入のハッシュを待たなければならない)、受信の経路こそ、Webhook 中継サービスがボトルネックを許されない場所だからです。

代わりに、cron が UTC の前日分を 1 回だけ封印します。封印は二度と再計算しません。保存期間を過ぎた古いイベントが削除された後で畳み込み直すと、違うルートになります。そして、公開済みのアンカーを黙って差し替えることこそ、このシステムが検出するために存在する改ざんだからです。

代償は、検知できない時間帯です。封印前の当日に行われた編集は、検知できません。数週間から数か月の単位で問われる紛争の証拠にとって、24 時間の封印間隔は問題になりません。1 時間以内の保証が必要な顧客が現れたら、改善策は App ごとのライブチェーンです。App はそれぞれ独立に直列化されるので、全体としては並列に書き込めます。その上限を明記したコメントを書いたうえで、単純なバージョンを出しました。

アンカーはプッシュではなくプル

チェーンだけでは、何も証明できません。データベースを握っている人なら、全体を再計算できるからです。アンカーは、AnyHook のサーバーが書き込めず、さかのぼった編集が表に出る場所に置く必要があります。最初の案では、封印のジョブが、保存したトークンを使って各アンカーを GitHub にプッシュしていました。その後、向きを逆にしました。

アンカーのリポジトリは、独自にスケジュールされた GitHub Action を実行し、AnyHook の公開 API を取得して、見たものをコミットします。AnyHook のサービスは、そのリポジトリの認証情報を一切持っていません。ワークフローは、履歴の書き換えも拒否します。毎回アンカーの全期間を取得し直し、以前にコミットしたアンカーが API の現在の返り値と食い違えば、上書きしてコミットするのではなく、目立つ形で失敗します。

サービスが書き込めないアンカーは、書き込めるアンカーより確実に価値があります。流れを逆にしたことで、トークンを扱う 30 行のコードも消えました。これは、ずっと保てる種類の単純化です。

第三者がこの主張を確かめるのに必要なものペイロード受信したものsha256リーフ畳み込みルートアンカーbody_hash は暗号化の前に取るため、どの手順にも AnyHook の鍵は要らない必要なツールは shasum と git clone だけ
検証には、AnyHook が持っているものは何も要らない。平文のボディをハッシュする意味は、まさにそこにある。

シェルだけで検証する

ここまでの内容は、すべて公開 API から再現できます。最初の日を手で確認すると、次のようになります。

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

これが、公開されている 2026-07-27 のアンカーです(イベントは 0 件で、起点も空なので、畳み込み全体は初期値を 1 回ハッシュしただけです)。2 日目はこの値を畳み込み、以降も同様です。GET /api/v1/anchors?day=YYYY-MM-DD は、監査する人がアカウントもキーも AnyHook の協力もなしに 1 日分を再計算するのに必要な、(app_id, root) の組を返します。

正直な限界

  • 上で書いたとおり、当日はまだ封印されていません。直列化しない書き込み経路の代償です
  • 個々の証明は、保存期間に縛られます。プランの保存期間に従ってイベントが削除されると、そのリーフは再計算できません。その日のルートは件数と順序を引き続きコミットしていますが、特定のペイロードを証明するには、そのイベントがまだ存在している必要があります。保存期間が長いほど、文字どおり長く証明できます
  • コミットメントは、この機能を出した日から始まっています。ただし、穴が 1 か所あります。封印の cron が 2026-08-01 まで認証できなかったため、07-29 から 07-31 までは一度も封印されていません。公開チェーンは 07-28 から 08-01 へ直接つながっています。今それらの日を封印すると、データベースの今日の内容をコミットすることになり、それこそ封印が防ぐために存在するものです。ボディのハッシュが導入される前に書き込まれたイベントは、ID とタイムスタンプだけでコミットされています。チェーンは、測っていなかった過去をさかのぼってカバーすることはできません
  • アンカーのリポジトリは、創業者の GitHub アカウントにあります。サービスは書き込めませんが、その所有者は書き込めます。こっそり書き換えるのを高くつくものにしているのは、force push された履歴がクローンを持っている誰にでも見えること、そして、コミット済みのアンカーが変わるとワークフローが失敗することです

どれも、ブロックチェーンを必要としません。Git はもともとハッシュチェーンで、GitHub はコミットにタイムスタンプを付け、Certificate Transparency は何年も前にこのパターンを実証しています。ここで唯一新しい判断は、公開の記録を書くペンを、サービスの手から離しておくことです。

すべての記事2026年7月30日 · 8 分

Webhook を、もう取りこぼさない。

URL を 1 つ変えるだけで、リトライ、イベントログ、1 クリックでの再送が使えます。先に動きを見ることもできます。新規登録して 30 秒の障害訓練を実行すると、サーバーがダウンしていてもイベントが失われない様子を確認できます。サーバーを用意する必要はありません。