HMAC(Hash-based Message Authentication Code)は、秘密鍵とメッセージから短い値(MAC)を計算する仕組みです。同じ鍵を持つ相手は同じ計算をして値を突き合わせ、一致すれば「鍵を持つ誰かが作ったメッセージで、途中で書き換えられていない」と判断できます。LINE や Chatwork の Webhook 署名、API リクエストの署名、HS256 の JWT、ワンタイムパスワードまで、多くの仕組みがこの計算の上に成り立っています。

この記事では、HMAC-SHA256 の計算を実際の値で 1 段ずつ確認し、そのあと Webhook の署名検証でつまずく点を整理します。数値はすべて HMAC 計算・署名検証ツールで再現でき、コードはサイトのテストで実行しています。

HMACとは:鍵付きハッシュでメッセージを認証する

HMAC は RFC 2104(1997 年)で定義され、米国では FIPS 198-1 として標準化されています。日本の電子政府推奨暗号リスト(CRYPTREC 暗号リスト)にも、メッセージ認証コードとして HMAC が載っています。式は次のとおりです。

HMAC(K, m) = H( (K0 XOR opad) || H( (K0 XOR ipad) || m ) )
  • H はハッシュ関数です。ブロック長 B は SHA-1 と SHA-256 で 64 バイト、SHA-384 と SHA-512 で 128 バイト。出力長 L は SHA-256 なら 32 バイトです。
  • K0 は鍵をちょうど B バイトにしたものです。B より長い鍵は先にハッシュし、短い鍵は後ろを 0x00 で埋めます。
  • ipad は 0x36 を B 個、opad は 0x5c を B 個並べたものです。|| は連結です。

つまり HMAC はハッシュを 2 回計算します。内側で鍵とメッセージを混ぜ、外側でもう一度鍵と内側の結果を混ぜます。

HMAC-SHA256 の計算を 1 段ずつ追う

LINE Developers の「Webhookの署名を検証する」に載っている例を使います。チャネルシークレットは 8c570fa6dd201bb328f1c1eac23a96d8、ボディは {"destination":"U8e742f61d673b39c7fff3cecb7536ef0","events":[]}、署名は GhRKmvmHys4Pi8DxkF4+EayaH0OqtJtaZxgTD9fMDLs= です。

チャネルシークレットは 32 文字の英数字ですが、LINE はこれを 16 進数としてではなく文字列として鍵にします。32 バイトなので 64 バイトのブロックに足りず、後ろの 32 バイトは 0x00 で埋まります。先頭の文字 8(0x38)と 0x36 の XOR は 0x0e、0x5c との XOR は 0x64 です。あとは SHA-256 を 2 回呼ぶだけです。

import { createHash, createHmac } from 'node:crypto';

const secret = Buffer.from('8c570fa6dd201bb328f1c1eac23a96d8');
const body = Buffer.from('{"destination":"U8e742f61d673b39c7fff3cecb7536ef0","events":[]}');
const k0 = Buffer.alloc(64);          // SHA-256 のブロック長
secret.copy(k0);                      // 短い鍵は 0x00 で埋める
const inner = createHash('sha256').update(k0.map((b) => b ^ 0x36)).update(body).digest();
const outer = createHash('sha256').update(k0.map((b) => b ^ 0x5c)).update(inner).digest('base64');
console.log(inner.toString('hex'));
console.log(outer);
console.log(outer === createHmac('sha256', secret).update(body).digest('base64'));

内側のハッシュは 839a5a21…、外側の結果を Base64 にすると GhRKmvmHys4Pi8DxkF4+EayaH0OqtJtaZxgTD9fMDLs= で、LINE のドキュメントの署名と一致します。ツールで形式を「LINE Messaging API」にして「例」を押しても同じ値になります。

「鍵とメッセージを連結してハッシュ」ではだめな理由

SHA-256(鍵 || メッセージ) で十分に見えますが、SHA-256 を含む SHA-2 は入力をブロックごとに処理し、最後の内部状態をそのまま出力する構造です(FIPS 180-4)。そのため値を見た第三者は、鍵を知らなくても続きから計算を再開し、鍵 || メッセージ || パディング || 追加データ のハッシュを作れます。これを伸長攻撃(length extension attack)と呼びます。

HMAC は外側のハッシュでこれを防ぎます。攻撃者に見えるのは内側の状態ではなく、鍵を混ぜた外側の結果だけです。また HMAC はハッシュの衝突耐性に頼らないため、MD5 そのものが衝突で破られた後も、RFC 6151(2011 年)は HMAC-MD5 について MAC として使う限り「実用的な脆弱性を示すものではない」と述べています。

ハッシュ・HMAC・デジタル署名の違い

ハッシュ(SHA-256)HMACデジタル署名(Ed25519・RSA)
鍵なし共有する秘密鍵 1 つ秘密鍵で署名、公開鍵で検証
正しい値を作れる人誰でも秘密鍵を持つ人秘密鍵の持ち主だけ
検証できる人誰でも秘密鍵を持つ人公開鍵を持つ誰でも
主な用途改ざん検知・ファイル照合Webhook・API 署名・Cookieソフトウェア配布・証明書

HMAC では検証する側も同じ値を作れるので、第三者に対して「誰が送ったか」を証明することはできません。それが必要なら公開鍵の署名を使います。Standard Webhooks の仕様も、HMAC-SHA256 の v1 と Ed25519 の v1a を両方定めています。

LINE と Chatwork の署名検証

LINE は受信したボディをそのまま入力、チャネルシークレットを鍵にして HMAC-SHA256 を計算し、Base64 にした値を x-line-signature ヘッダーで送ります。上の例がそのままの手順です。

Chatwork は鍵の作り方が違います。Chatwork API の Webhook ドキュメントには「トークンをBase64デコードしたバイト列を秘密鍵とします」とあり、トークンの文字列をそのまま鍵にすると一致しません。説明用のトークン Y2hhdHdvcmstd2ViaG9vay10b2tlbi1zYW1wbGUtMDE= で計算すると次のとおりです。

import base64
import hashlib
import hmac

token = "Y2hhdHdvcmstd2ViaG9vay10b2tlbi1zYW1wbGUtMDE="
body = b'{"webhook_setting_id":"12345","webhook_event_type":"mention_to_me"}'
received = "co2OlvkgxgqT06s8DH9nYIM3DZQ+rUqAmNIBqGH6RLA="

key = base64.b64decode(token)  # 文字列ではなくデコードしたバイト列を鍵にする
expected = base64.b64encode(hmac.new(key, body, hashlib.sha256).digest()).decode()
print(expected)
print(hmac.compare_digest(expected, received))

ツールで形式を「Chatwork Webhook」にすると、デコードまで自動で行います。逆に LINE などの形式でトークンを入れると、Sn51C3lS… という別の値になります。この値と正しい署名を並べて貼ると、ツールは「鍵をテキスト(UTF-8)として読むと一致します」と原因を表示します。

GitHub や Stripe も仕組みは同じで、違いは署名対象と書式です。GitHub はボディだけを署名して sha256= + 16 進数、Stripe はタイムスタンプと「.」をボディの前に付けて t=…,v1=… で送ります。

GitHub がドキュメントで公開しているシークレット It's a Secret to Everybody とペイロード Hello, World! の結果は X-Hub-Signature-256: sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 です。

署名検証が失敗する典型的な原因

LINE のドキュメントは、本物の Webhook なのに検証が失敗する原因として、ボディのパース・整形・エスケープの解釈、HMAC-SHA256 以外のアルゴリズム、別チャネルのシークレット、UTF-8 以外での処理を挙げています。実際に多いのは次のパターンです。

  • JSON を整形した:ログに出すために整形したボディや、一度パースして文字列に戻したボディでは一致しません。LINE の例を 2 スペースでインデントして貼ると、ツールの結果は 6OxvZxMNBcZuHtZxZWVgbXLCJgDtEGgUtA/O2NM5Z9Y= になり、受信した署名を貼ると「1 行に詰めた JSON と一致します」と表示されます。
  • 末尾の改行:ターミナルやエディターからコピーすると最後に改行が付きがちです。改行 1 つでも値はまったく変わります。
  • 改行コード:Windows のツールを経由して LF が CRLF に変わると一致しません。LINE のドキュメントも、UTF-8 以外で処理すると LF が CRLF に変わることがあると注意しています。
  • 文字コード:日本語を Shift_JIS のまま計算すると、UTF-8 で署名した値とは一致しません。「署名」という 2 文字は UTF-8 で 6 バイト、Shift_JIS では 8f9096bc の 4 バイトです。
  • 全角文字:IME がオンのまま 16 進数の鍵を入力すると全角になることがあります。ツールの 16 進数・Base64 欄は全角英数字を半角として読み、そのことを表示します。

鍵とアルゴリズムの選び方

  • アルゴリズム:相手の仕様に合わせます。新しく設計するなら HMAC-SHA256 が標準的です。NIST は SHA-1 を 2030 年末までに廃止する方針です。
  • 鍵の長さ:RFC 2104 §3 は出力長より短い鍵を「strongly discouraged(強く非推奨)」としています。HMAC-SHA256 なら 32 バイト以上の乱数にします。ブロック長より長い鍵は最初にハッシュされるので、長くしても強くはなりません。
  • 鍵の表現:鍵はバイト列です。0b0b… という 40 文字をテキストとして扱えば 40 バイト、16 進数として読めば 20 バイトです。RFC のテストベクターは 16 進数、多くの Webhook は文字列のまま、Chatwork や Standard Webhooks は Base64 デコードしたバイト列を鍵にします。
  • 比較:サーバーでは定数時間で比較します。Node.js の crypto.timingSafeEqual(長さが違うと例外になるので先に長さを比べる)、Python の hmac.compare_digest が使えます。
  • 切り詰め:RFC 2104 §5 は出力の左側だけを使うことを認めていますが、出力の半分以上かつ 80 ビット以上が条件です。

実装を確かめるテストベクター

自分で書いた HMAC のコードは、公開されている値で確かめてから使います。

出典鍵メッセージ期待値
RFC 4231 テストケース 10b × 20(16 進数)Hi ThereHMAC-SHA256 b0344c61…2e32cff7
RFC 4231 テストケース 2Jefewhat do ya want for nothing?HMAC-SHA256 5bdcc146…64ec3843
RFC 4231 テストケース 6aa × 131(16 進数)Test Using Larger Than Block-Size Key - Hash Key FirstHMAC-SHA256 60e43159…0ee37f54
LINE ドキュメント8c570fa6dd201bb328f1c1eac23a96d8上の JSONGhRKmvmH…D9fMDLs=
GitHub ドキュメントIt's a Secret to EverybodyHello, World!sha256=757107ea…8b043e17

テストケース 6 は、長い鍵を先にハッシュし忘れた実装を見つけるためのものです。ツールはこれらを含む RFC 2104・RFC 2202・RFC 4231 の全テストベクターと、Node.js・Python の結果と照合した多数のランダム入力で確認しています。計算はブラウザ内だけで行い、鍵もボディも送信しません。