✍️

Cloudflare Workers + HonoでLINE Botを作るならWeb標準APIだけで署名検証しよう

に公開

はじめに

Node.js固有のAPIに依存せず、Web標準 (Web Crypto API) のみを使ってLINE BotのWebhook署名検証を行うHono用ミドルウェア hono-linebot-middleware を作成しました。

https://www.npmjs.com/package/@nakanoaas/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のみで完結させることにこだわりました。

  1. パフォーマンス: 互換レイヤーを挟むオーバーヘッドを極力減らしたい。
  2. ポータビリティ: 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などでハッシュ化し、そのハッシュ値同士を比較することで擬似的にタイミングセーフを実現していました。

  1. crypto.subtle.sign でHMAC署名を生成
  2. 生成した署名と、ヘッダーの署名をそれぞれさらにハッシュ化
  3. ハッシュ値を比較

この方法は賢いですが、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.verifyPromise<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:cryptotimingSafeEqual がなくても、Web Crypto APIの verify メソッドを使えば安全かつ高速に署名検証ができます。
  • Cloudflare Workersなどのエッジ環境では、可能な限りWeb標準APIを使うことで、ポータビリティとパフォーマンスを両立できます。
  • AIも万能ではないので、たまには一次情報(MDNなどのドキュメント)をAPIリストの上から下まで眺めてみるのも大事です。

リポジトリはこちらです。Cloudflare WorkersでLINE Botを作る際はぜひ使ってみてください。

https://github.com/nakanoasaservice/hono-linebot-middleware

YOSHINANI

Discussion