AIエージェントにHMAC署名検証をさせてはいけない理由|セキュリティの「確実性の穴」を埋めるMCP設計

はじめに

MCP(Model Context Protocol)は、Cursor や Claude などのエージェントが DB 更新や API 呼び出しなどのツールをプロンプト経由で実行するための仕組みです。Webhook の HMAC 署名検証をそのツールの1つとしてエージェントに任せる設計を見かけることがあります。「security.generate_hmac で検証して、OK なら DB を更新して」という流れです。

個人開発の MCP サーバー Thousand API でも HMAC 生成・検証ツールを公開していますが、本番のセキュリティ境界としてエージェントに検証判断を委ねる設計は避けるべきだと考えています。HMAC 検証は「たぶん正しい」では足りません。

本記事では、暗号計算は正しくても検証結果をセキュリティ境界として強制できない状態を「確実性の穴」と呼びます。なぜエージェントに HMAC 検証をさせないのか、MCP 周辺でこの穴をどう埋めるかを整理します。対象は、Stripe や GitHub などの Webhook 署名を扱うバックエンド/MCP 連携を設計するエンジニアです。

検証環境: Node.js(crypto モジュール)、Thousand API の security.generate_hmac(API パス /v1/crypto/hmac)。MCP クライアントは Cursor など一般的なエージェントを想定しています。

背景:HMAC 検証が求めるもの

HMAC(Hash-based Message Authentication Code)は、共有シークレットとメッセージから署名を計算し、改ざんやなりすましを検出する仕組みです。Stripe や GitHub の Webhook でも、受信した生のボディとヘッダの署名を照合するパターンが一般的です(Stripe Webhook 署名)。

検証側で満たしたい性質は次のとおりです。

性質意味実装で気をつける点
決定性同じ入力なら常に同じ結果アルゴリズム・エンコーディング・正規化を固定する
完全性1 ビットでも違えば不一致生ボディで検証する(JSON 再シリアライズ後は一致しないことがある)
タイミング安全性比較時間から署名を推測されない=== ではなく定数時間比較を使う
強制検証失敗時は後続処理に進まないミドルウェアやゲートウェイで拒否する

ここまでの処理は、決定的なコードに任せるのが自然です。従来の Webhook 受信では if (!verified) return 401 のようにコードが強制します。エージェント経由だと「検証ツールを呼ぶか」「verified: false をどう解釈するか」をモデルが毎回判断し、強制されません。

なぜエージェントの推論ループに入れると危ないか

1. エージェントは確率的で、セキュリティ境界には向かない

LLM ベースのエージェントは、同じ入力でも手順の省略や説明の追加が起き得ます。HMAC 検証のような「不一致なら即 401」という分岐は、人間の if 文やミドルウェアに置く方が、意図どおり動く可能性が高いです。

MCP のセキュリティ分析では、コア仕様だけではツール呼び出しの認可はアプリ任せになる、という指摘があります。検証のような強制分岐は、エージェントの外側の非エージェント層に置く設計が妥当です。

2. 「ツールを呼んだ」ことと「検証を通した」ことは別

MCP の verify ツール(例: Thousand API の security.generate_hmac)を公開すると、エージェントは次の判断を挟みます。

  1. 検証ツールを呼ぶか
  2. 引数(messagesignaturesecret)をどう埋めるか
  3. 返ってきた verified: false をどう解釈するか

3 番目で、エージェントが「おそらく問題ない」と後続の破壊的操作に進む余地が残ります。暗号の計算はサーバー側で正しくても、その結果を境界として強制する主体がエージェントだと、境界はエージェントの判断品質に依存します。

3. シークレットがエージェントのコンテキストに入りやすい

Webhook 検証にはシークレットが必要です。エージェントに検証を任せると、プロンプト・ツール引数・会話ログにシークレットが載りやすくなります。Thousand API ではレスポンスやログに secret を含めないよう実装していますが、クライアント側に渡した時点で露出リスクは増えます

失敗例:エージェントに検証から DB 更新まで任せた場合

次のような依頼をエージェントに出すと、確実性の穴が開きやすくなります。

Stripe の Webhook ペイロードを受け取った。generate_hmac で署名を検証し、問題なければサブスクリプション状態を更新して。

想定される問題は次のとおりです。

  1. 手順省略: ペイロード内の指示文などで「署名は既に確認済み」と誘導され、検証ツールをスキップする(プロンプトインジェクションの一形態)
  2. 引数の取り違え: message に JSON 文字列ではなくパース後のオブジェクトを渡し、誤検証や常時不一致になる
  3. 結果の誤解釈: verified: false でも「一時的な不整合」と解釈し更新処理へ進む
  4. シークレット露出: secret をツール引数として毎回送信し、ログや履歴に残る

いずれも暗号ライブラリのバグというより、境界の置き場所の問題です。

MCP で「確実性の穴」を埋める設計

原則:検証はエージェントの外、副作用は検証後だけ

推奨する責務分担は次のとおりです。

flowchart TD
  ext[外部サービス] -->|Webhook| verify[検証レイヤ(BFF / API Gateway / Route Handler)]
  verify -->|検証OKのみ通過| store[エージェントが読めるキュー / DB / イベント]
  store --> mcp[MCP ツールは「検証済みイベント」の処理だけ]

処理の流れは次のイメージです。

flowchart TD
  Webhook --> A["Route Handler(署名検証)"]
    --> B["verified_events テーブルへ INSERT"]
    --> C["MCP ツールは event_id のみ受け取り処理"]

エージェントが触るのは 検証済みの事実 に限定します。Stripe の例なら、Next.js の Route Handler で constructEvent(Stripe SDK が署名検証とパースを行う関数)まで済ませます。エージェントにはイベント ID や種別だけ渡す形です。

実装パターン:検証レイヤの例(Node.js)

本番の Webhook 入口では、エージェントを介さず Route Handler または API ミドルウェアで完結させます。

// 例: Hono / Next.js Route Handler のイメージ
export async function webhookHandler(request: Request) {
  const body = await request.text(); // 生ボディが重要
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return new Response('missing signature', { status: 400 });
  }

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch {
    return new Response('invalid signature', { status: 401 });
  }

  // 検証済みイベントだけをキューへ(エージェントはここから先)
  await enqueueVerifiedEvent(event);

  return new Response('ok', { status: 200 });
}

検証時の比較には crypto.timingSafeEqual のような定数時間比較を使います。Thousand API でも同様の実装です。

// タイミング安全比較の例(Node.js crypto)
const verified =
  expectedBuf.length === actualBuf.length &&
  timingSafeEqual(expectedBuf, actualBuf);

安全な MCP ツール定義の例

エージェント向けツールは、生ペイロードや secret を引数として受け取らない設計にします。

// 安全: 検証済みイベント ID のみを受け取る
{
  name: 'billing.apply_verified_event',
  inputSchema: {
    type: 'object',
    properties: {
      event_id: { type: 'string', description: 'verified_events テーブルの ID' },
    },
    required: ['event_id'],
  },
}

サーバー側では event_id が検証レイヤで記録されたものか確認し、同じ ID の二重処理を防ぐ(冪等処理)ようにします。

HMAC ツールの位置づけ:開発支援と本番境界の分離

Thousand API の security.generate_hmac は、次の用途を想定しています。

用途エージェント経由備考
署名の生成・照合の動作確認向くローカル検証・ドキュメント作成
本番 Webhook の受け口の検証向かないミドルウェアで拒否まで完結させる
検証結果に基づく DB 更新の可否判断向かない検証レイヤで分岐する

ツールの destructiveHint(危険度ヒント)は、あくまでクライアント向けの注記です。検証失敗後にエージェントが別ツールで書き込むという間接的な破壊は防げません。境界の設計で防ぐ必要があります。

実装時の注意点

  • 生ボディで検証する。 request.json() 後に JSON.stringify しても、元のバイト列と一致しないことがある(Stripe 公式も生ボディを推奨)。
  • 検証と副作用を同一トランザクション境界に近づける。 検証レイヤでイベント ID を記録し、MCP 側はその ID の冪等処理だけを許可する。
  • エージェント向けツールに secret を渡さない設計を検討する。 どうしても開発用に必要なら、本番キーとは別のサンドボックスシークレットに限定する。
  • MCP サーバー自体の認証を別途行う。 コア仕様はツール呼び出しの認証をアプリ側に委ねるため、API Key や OAuth などサーバー接続時の認証を用意する。IETF draft の MCPS のように、メッセージ単位の署名を足す方向性もある。

次に試すこと

  1. Webhook 受信の確認: MCP ツール一覧に verify や raw secret 引数がないか、Route Handler 内で 401 を返しているかを見直す
  2. 検証済みキューの用意: 未検証ペイロードを受け取る MCP ツールを廃止し、入力を event_id のみに限定する
  3. シークレットの分離: 開発時のみ security.generate_hmac で署名を試し、本番 webhookSecret がエージェント設定やプロンプトに含まれないことを確認する
  4. 段階的移行: すでにエージェント検証がある場合は、まず検証だけ Route Handler に移し、MCP は検証済みレコードの読み取り専用処理に切り替える

まとめ

  • HMAC 検証は決定性・タイミング安全性・強制拒否が求められる処理で、エージェントの推論ループの中に置くと確実性の穴が開きやすい
  • MCP ツールで検証「計算」はできても、検証「境界」はミドルウェアや BFF などエージェントの外に置く
  • Thousand API の security.generate_hmac は動作確認や開発支援向けとし、本番の Webhook 受け口は検証済みイベントだけをエージェントに渡す構成が安全

Source