Cloudflare Workers + HonoでLINE Botを作るならWeb標準APIだけで署名検証しよう
はじめに
Node.js固有のAPIに依存せず、Web標準 (Web Crypto API) のみを使ってLINE BotのWebhook署名検証を行うHono用ミドルウェア hono-linebot-middleware を作成しました。
この記事では、Cloudflare WorkersなどのエッジランタイムでLINE Botを動かす際に直面する「署名検証の壁」と、それをWeb標準APIだけでどう乗り越えたかという試行錯誤のプロセスを共有します。
動機
きっかけは、Cloudflare WorkersでLINEからメッセージを受け取るだけのシンプルなBotを作りたかったことでした。
Cloudflare WorkersはWeb標準に準拠したAPI (WinterCG) をベースにしていますが、既存のLINE Bot SDKや多くの実装例は Node.js の crypto モジュールに依存しています。
もちろん、Cloudflare Workersでも nodejs_compat フラグを有効にすれば node:crypto は使えます。しかし、以下の理由から今回はWeb標準APIのみで完結させることにこだわりました。
- パフォーマンス: 互換レイヤーを挟むオーバーヘッドを極力減らしたい。
- ポータビリティ: Node.js環境に依存しない、純粋なWeb標準コードとして保ちたい。
既存実装はどうやっているのか?
まず、公式の line-bot-sdk-nodejs がどのように署名検証を行っているか確認しました。
// 既存ライブラリの処理イメージ
import { createHmac, timingSafeEqual } from "node:crypto";
const signature = req.headers['x-line-signature'];
const body = ...; // リクエストボディ
// 1. Channel Secretを鍵としてHMAC-SHA256署名を生成
const generatedSignature = createHmac('sha256', channelSecret)
.update(body)
.digest('base64');
// 2. タイミング攻撃を防ぐために timingSafeEqual で比較
const safe = timingSafeEqual(
generatedSignature,
Buffer.from(signature, 'base64')
);
ここでのポイントは crypto.timingSafeEqual です。署名の検証において、単純な文字列比較(===)を行うと、不一致が発生した時点ですぐにfalseが返るため、応答時間のわずかな差から攻撃者に正解の署名を推測される「タイミング攻撃」のリスクがあります。Node.jsではこれを防ぐために定数時間で比較を行う timingSafeEqual が用意されています。
しかし、Web標準APIには timingSafeEqual に相当する関数が存在しません。
試行錯誤:Web標準での再現
アプローチ1: Honoの実装を参考にする
Hono自体もWeb標準で動作することを重視しているフレームワークです。Cookieの署名検証などで同様の比較が必要なはずなので、実装を確認してみました(src/utils/buffer.ts)。
Honoの実装では、比較したい2つの文字列をさらにSHA-256などでハッシュ化し、そのハッシュ値同士を比較することで擬似的にタイミングセーフを実現していました。
-
crypto.subtle.signでHMAC署名を生成 - 生成した署名と、ヘッダーの署名をそれぞれさらにハッシュ化
- ハッシュ値を比較
この方法は賢いですが、LINE Botの署名検証のためだけに「HMAC計算 + 検証用ハッシュ計算×2」を行うのは、計算効率の観点で少し気になりました。
アプローチ2: APIドキュメントの再探索
「もっとネイティブな方法があるはずだ」と考え、MDNの Web Crypto API (SubtleCrypto) のドキュメントをメソッド一つ一つ確認していきました。
最初は「署名を生成して(sign)、比較する」ことばかり考えていましたが、リストの中に verify というメソッドを見つけました。
SubtleCrypto.verify()
verify()メソッドは、指定された署名の検証を行います。
これです。
自分で「生成して比較」するのではなく、「検証」そのものをブラウザ(ランタイム)のネイティブ実装に任せてしまえばいいのです。これならタイミング攻撃の対策もランタイム側の実装(RustやC++など)に委譲できます。
実は最初にAIに相談したときは、sign して自前で比較するコードを提案されていました。AIもここまでは気が回らなかったようです。
完成したWeb標準オンリーの検証コード
発見した crypto.subtle.verify を使って実装したのが以下のコードです。驚くほどシンプルになりました。
1. 鍵のインポート
まず、Channel Secret(文字列)をWeb Crypto APIで使える CryptoKey に変換します。
export async function importKeyFromChannelSecret(
channelSecret: string,
): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyBytes = encoder.encode(channelSecret);
return await crypto.subtle.importKey(
"raw",
keyBytes,
{
name: "HMAC",
hash: { name: "SHA-256" },
},
false, // 鍵をエクスポート不可にする
["verify"], // verify用途でのみ使用する
);
}
2. 署名の検証
そして検証処理です。crypto.subtle.verify は Promise<boolean> を返すので、結果をそのまま信用できます。
import { decodeBase64 } from "hono/utils/encode";
export async function validateSignature(
body: BufferSource, // リクエストボディ (ArrayBuffer)
key: CryptoKey,
signature: string, // x-line-signature ヘッダー
): Promise<boolean> {
const signatureBytes = decodeBase64(signature);
return await crypto.subtle.verify(
"HMAC",
key,
signatureBytes,
body // 署名対象のデータ
);
}
Honoミドルウェアとしてまとめる
これらを組み合わせて、Honoのミドルウェアとして実装しました。
export function lineBotMiddleware(channelSecret: string): MiddlewareHandler {
const cryptoKey = importKeyFromChannelSecret(channelSecret);
return async (c, next) => {
const signature = c.req.header("x-line-signature");
if (!signature) {
throw new HTTPException(401, { message: "no signature" });
}
const body = await c.req.arrayBuffer();
// 待機しておいたPromise<CryptoKey>を使用
const isValid = await validateSignature(body, await cryptoKey, signature);
if (!isValid) {
throw new HTTPException(401, { message: "signature validation failed" });
}
await next();
};
}
まとめ
-
node:cryptoのtimingSafeEqualがなくても、Web Crypto APIのverifyメソッドを使えば安全かつ高速に署名検証ができます。 - Cloudflare Workersなどのエッジ環境では、可能な限りWeb標準APIを使うことで、ポータビリティとパフォーマンスを両立できます。
- AIも万能ではないので、たまには一次情報(MDNなどのドキュメント)をAPIリストの上から下まで眺めてみるのも大事です。
リポジトリはこちらです。Cloudflare WorkersでLINE Botを作る際はぜひ使ってみてください。
Discussion