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 テストケース 1 | 0b × 20(16 進数) | Hi There | HMAC-SHA256 b0344c61…2e32cff7 |
| RFC 4231 テストケース 2 | Jefe | what do ya want for nothing? | HMAC-SHA256 5bdcc146…64ec3843 |
| RFC 4231 テストケース 6 | aa × 131(16 進数) | Test Using Larger Than Block-Size Key - Hash Key First | HMAC-SHA256 60e43159…0ee37f54 |
| LINE ドキュメント | 8c570fa6dd201bb328f1c1eac23a96d8 | 上の JSON | GhRKmvmH…D9fMDLs= |
| GitHub ドキュメント | It's a Secret to Everybody | Hello, World! | sha256=757107ea…8b043e17 |
テストケース 6 は、長い鍵を先にハッシュし忘れた実装を見つけるためのものです。ツールはこれらを含む RFC 2104・RFC 2202・RFC 4231 の全テストベクターと、Node.js・Python の結果と照合した多数のランダム入力で確認しています。計算はブラウザ内だけで行い、鍵もボディも送信しません。