diff --git a/src/line/monitor.ts b/src/line/monitor.ts index 9b40e4460..c6241d97d 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -1,10 +1,10 @@ import type { WebhookRequestBody } from "@line/bot-sdk"; import type { IncomingMessage, ServerResponse } from "node:http"; -import crypto from "node:crypto"; import type { ClawdbotConfig } from "../config/config.js"; import { danger, logVerbose } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLineBot } from "./bot.js"; +import { validateLineSignature } from "./signature.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import { @@ -85,11 +85,6 @@ export function getLineRuntimeState(accountId: string) { return runtimeState.get(`line:${accountId}`); } -function validateLineSignature(body: string, signature: string, channelSecret: string): boolean { - const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); - return hash === signature; -} - async function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/src/line/signature.test.ts b/src/line/signature.test.ts new file mode 100644 index 000000000..8bd9b1f3f --- /dev/null +++ b/src/line/signature.test.ts @@ -0,0 +1,27 @@ +import crypto from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { validateLineSignature } from "./signature.js"; + +const sign = (body: string, secret: string) => + crypto.createHmac("SHA256", secret).update(body).digest("base64"); + +describe("validateLineSignature", () => { + it("accepts valid signatures", () => { + const secret = "secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, sign(rawBody, secret), secret)).toBe(true); + }); + + it("rejects signatures computed with the wrong secret", () => { + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, sign(rawBody, "wrong-secret"), "secret")).toBe(false); + }); + + it("rejects signatures with a different length", () => { + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, "short", "secret")).toBe(false); + }); +}); diff --git a/src/line/signature.ts b/src/line/signature.ts new file mode 100644 index 000000000..771a950ff --- /dev/null +++ b/src/line/signature.ts @@ -0,0 +1,18 @@ +import crypto from "node:crypto"; + +export function validateLineSignature( + body: string, + signature: string, + channelSecret: string, +): boolean { + const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); + const hashBuffer = Buffer.from(hash); + const signatureBuffer = Buffer.from(signature); + + // Use constant-time comparison to prevent timing attacks. + if (hashBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(hashBuffer, signatureBuffer); +} diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 846d8d796..9986617f9 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -1,8 +1,8 @@ import type { Request, Response, NextFunction } from "express"; -import crypto from "node:crypto"; import type { WebhookRequestBody } from "@line/bot-sdk"; import { logVerbose, danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; +import { validateLineSignature } from "./signature.js"; export interface LineWebhookOptions { channelSecret: string; @@ -10,20 +10,6 @@ export interface LineWebhookOptions { runtime?: RuntimeEnv; } -function validateSignature(body: string, signature: string, channelSecret: string): boolean { - const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); - const hashBuffer = Buffer.from(hash); - const signatureBuffer = Buffer.from(signature); - - // Use constant-time comparison to prevent timing attacks - // Ensure buffers are same length before comparison to prevent timing leak - if (hashBuffer.length !== signatureBuffer.length) { - return false; - } - - return crypto.timingSafeEqual(hashBuffer, signatureBuffer); -} - function readRawBody(req: Request): string | null { const rawBody = (req as { rawBody?: string | Buffer }).rawBody ?? @@ -61,7 +47,7 @@ export function createLineWebhookMiddleware(options: LineWebhookOptions) { return; } - if (!validateSignature(rawBody, signature, channelSecret)) { + if (!validateLineSignature(rawBody, signature, channelSecret)) { logVerbose("line: webhook signature validation failed"); res.status(401).json({ error: "Invalid signature" }); return;