🗝️

Cloudflare Workers with HonoでLINE Messaging APIのWebhookの署名を検証する

2024/10/30に公開

リクエストがLINEプラットフォームから送信されていることを確認するために、リクエストヘッダーのx-line-signatureに含まれる署名を検証する必要があります。
こちらがドキュメントです。

検証の方法

署名の検証の手順は以下のようになります。

  1. リクエストボディのダイジェストを計算します。チャネルシークレットを秘密鍵としてHMAC-SHA256アルゴリズムを使用します
  2. ダイジェストをBase64エンコードし、リクエストヘッダーのx-line-signatureに含まれる署名と一致するかどうかを確認します。

これを実装する方法として、ドキュメントに記載のコードは以下の通りになっています。

const crypto = require("crypto");

const channelSecret = "..."; // Channel secret string
const body = "..."; // Request body string
const signature = crypto
  .createHmac("SHA256", channelSecret)
  .update(body)
  .digest("base64");
// Compare x-line-signature request header and the signature

このsignaturex-line-signatureに含まれる署名と一致するかどうかを確認すればOKです。

xLineSignature === signature

しかし、これについてchatgpt o1で確認すると、タイミング攻撃に対して脆弱だと判断されました。

タイミング攻撃とは

タイミング攻撃とは、コンピュータシステムが行う処理の時間差を観測し、その情報から秘密情報(例:パスワード、暗号鍵など)を推測する攻撃手法です。

比較する際にかかる時間の微妙な違いから、正しい文字列やバイトの位置を少しずつ推測することが可能です。特に秘密情報(APIキー、トークン、暗号鍵など)を比較する際、単純な文字列比較(===や!=)を使うと、正しい部分が一致するほど処理が速くなり、異なる部分があると処理が遅くなる場合があるため、タイミング差が攻撃者に利用されることがあります。

とのことです。
この対策として、常に同じ時間をかけて比較処理を行う方法を用いる必要があります。
具体的には、Node.jsのcrypto.timingSafeEqual()がよく使われるようです。

line-bot-sdk-nodejsのソースコード

実際、line-bot-sdk-nodejsのvalidate-signature.tsのコードを見ると、確かにcrypto.timingSafeEqual()が使われていました。

Node.jsのCryptoとBufferを使った検証

Cloudflare Workersでは、Node.jsのCryptoBufferが利用可能ですので、これらを使用すると以下のようにできます。

import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
import crypto from "node:crypto";
import { Buffer } from "node:buffer";

type Bindings = {
  CHANNEL_SECRET: string;
};

const verifySignature = createMiddleware<{ Bindings: Bindings }>(
  async (c, next) => {
    const body = await c.req.text();
    const requestSignature = c.req.header("x-line-signature");

    if (!requestSignature) {
      throw new HTTPException(401, { message: "Signature is missing" });
    }

    const expectedSignatureBuffer = crypto
      .createHmac("sha256", c.env.CHANNEL_SECRET)
      .update(body)
      .digest();

    const requestSignatureBuffer = Buffer.from(requestSignature, "base64");

    if (
      expectedSignatureBuffer.length !== requestSignatureBuffer.length ||
      !crypto.timingSafeEqual(expectedSignatureBuffer, requestSignatureBuffer)
    ) {
      throw new HTTPException(401, {
        message: "Invalid signature",
      });
    }

    await next();
  }
);

export default verifySignature;

Web Crypto APIを使った検証

Web Crypto APIを使った場合は、以下のように検証できる。

import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";

type Bindings = {
  CHANNEL_SECRET: string;
};

const webCryptoVerifySignature = createMiddleware<{ Bindings: Bindings }>(
  async (c, next) => {
    const body = await c.req.text();
    const requestSignature = c.req.header("x-line-signature");

    if (!requestSignature) {
      throw new HTTPException(401, { message: "Signature is missing" });
    }

    const encoder = new TextEncoder();

    const key = await crypto.subtle.importKey(
      "raw",
      encoder.encode(c.env.CHANNEL_SECRET),
      { name: "HMAC", hash: { name: "SHA-256" } },
      false,
      ["sign"]
    );

    const signatureBuffer = await crypto.subtle.sign(
      "HMAC",
      key,
      encoder.encode(body)
    );

    const expectedSignature = btoa(
      String.fromCharCode(...new Uint8Array(signatureBuffer))
    );

    if (requestSignature !== expectedSignature) {
      throw new HTTPException(401, {
        message: "Invalid signature",
      });
    }

    await next();
  }
);

export default webCryptoVerifySignature;

Discussion