From b33fa2b941499bed6e70c50f82938fa27893678c Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Mon, 23 Mar 2026 18:39:20 +0000 Subject: [PATCH] refactor(webhook): extract verifyHmac helper and createWebhookVerifier factory --- src/router/webhookVerification.ts | 257 ++++++++++-------- src/webhook/signatureVerification.ts | 148 ++++++---- tests/unit/router/webhook-signature.test.ts | 99 +++++++ .../webhook/signatureVerification.test.ts | 123 +++++++++ 4 files changed, 464 insertions(+), 163 deletions(-) diff --git a/src/router/webhookVerification.ts b/src/router/webhookVerification.ts index d5e5668e..da279c79 100644 --- a/src/router/webhookVerification.ts +++ b/src/router/webhookVerification.ts @@ -16,6 +16,9 @@ import { import { loadProjectConfig, routerConfig } from './config.js'; import { resolveWebhookSecret } from './platformClients/credentials.js'; +/** The set of platforms that have a webhook secret in {@link resolveWebhookSecret}. */ +type WebhookPlatform = 'github' | 'trello' | 'jira' | 'sentry'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -65,92 +68,150 @@ export function buildTrelloCallbackUrl( } // --------------------------------------------------------------------------- -// verifySignature callbacks +// createWebhookVerifier factory // --------------------------------------------------------------------------- /** - * verifySignature callback for the Trello webhook handler. - * Returns null to skip verification when no secret is configured (backwards compat). + * Configuration for {@link createWebhookVerifier}. + * + * @template TProjectId - The type used to identify a project (e.g. string). */ -export async function verifyTrelloWebhookSignature( - c: Context, - rawBody: string, -): Promise<{ valid: boolean; reason: string } | null> { - const signatureHeader = c.req.header('x-trello-webhook'); - const boardId = extractTrelloBoardId(rawBody); +export interface WebhookVerifierConfig { + /** + * Extract the platform identifier (board ID, repo name, project key, …) + * from the raw request body and/or Hono context. + * Return `undefined` to skip verification (no identifier → no project match). + */ + extractIdentifier: (c: Context, rawBody: string) => string | undefined; + /** + * Find the project that owns this webhook by matching the extracted + * identifier. Return `undefined` when no project matches (skip verification). + */ + findProject: ( + identifier: string, + projects: Array>, + ) => { id: TProjectId } | undefined; + /** Platform name passed to `resolveWebhookSecret` (e.g. `'github'`). */ + platform: WebhookPlatform; + /** Header name that carries the signature. */ + headerName: string; + /** + * Verify the raw signature string against the body and secret. + * Return `true` if valid. + */ + verify: (rawBody: string, signatureHeader: string, secret: string, c: Context) => boolean; + /** Human-readable label used in the mismatch error reason (e.g. `'GitHub'`). */ + platformLabel: string; +} - if (!boardId) return null; +/** + * Factory that creates a `verifySignature` callback for Hono webhook handlers. + * + * All four platform verifiers follow the same pattern: + * 1. Extract a header value (signature). + * 2. Extract a platform identifier from the body (board ID, repo, project key…). + * 3. Look up the project in config. + * 4. Resolve the webhook secret for that project. + * 5. Verify the signature, returning a structured result. + * + * `createWebhookVerifier` captures steps 1–5 in a single reusable closure, + * parameterised by the small per-platform details supplied in `config`. + */ +export function createWebhookVerifier( + config: WebhookVerifierConfig, +): (c: Context, rawBody: string) => Promise<{ valid: boolean; reason: string } | null> { + const { extractIdentifier, findProject, platform, headerName, verify, platformLabel } = config; - const { projects } = await loadProjectConfig(); - const project = projects.find((p) => p.trello?.boardId === boardId); - if (!project) return null; + return async function verifyWebhookSignature( + c: Context, + rawBody: string, + ): Promise<{ valid: boolean; reason: string } | null> { + const signatureHeader = c.req.header(headerName); + const identifier = extractIdentifier(c, rawBody); - const secret = await resolveWebhookSecret(project.id, 'trello'); - if (!secret) return null; // No secret configured — skip verification + if (!identifier) return null; - if (!signatureHeader) { - return { valid: false, reason: 'Missing signature header' }; - } + const { projects } = await loadProjectConfig(); + // Cast is safe: loadProjectConfig returns typed project objects; we only + // need `id` from the result, and findProject may do deeper matching. + const project = findProject(identifier, projects as unknown as Array>); + if (!project) return null; + + const secret = await resolveWebhookSecret(project.id as string, platform); + if (!secret) return null; // No secret configured — skip verification + + if (!signatureHeader) { + return { valid: false, reason: 'Missing signature header' }; + } - const callbackUrl = buildTrelloCallbackUrl( - c.req.header('host'), - c.req.header('x-forwarded-proto'), - ); - const valid = verifyTrelloSignature(rawBody, callbackUrl, signatureHeader, secret); - return valid - ? { valid: true, reason: 'Signature valid' } - : { valid: false, reason: 'Trello signature mismatch' }; + const valid = verify(rawBody, signatureHeader, secret, c); + return valid + ? { valid: true, reason: 'Signature valid' } + : { valid: false, reason: `${platformLabel} signature mismatch` }; + }; } +// --------------------------------------------------------------------------- +// verifySignature callbacks (one per platform) +// --------------------------------------------------------------------------- + /** - * verifySignature callback for the GitHub webhook handler. + * verifySignature callback for the Trello webhook handler. * Returns null to skip verification when no secret is configured (backwards compat). */ -export async function verifyGitHubWebhookSignature( - c: Context, - rawBody: string, -): Promise<{ valid: boolean; reason: string } | null> { - const signatureHeader = c.req.header('X-Hub-Signature-256'); +export const verifyTrelloWebhookSignature = createWebhookVerifier({ + headerName: 'x-trello-webhook', + platform: 'trello', + platformLabel: 'Trello', + extractIdentifier: (_c, rawBody) => extractTrelloBoardId(rawBody), + findProject: (boardId, projects) => + projects.find((p) => (p.trello as Record | undefined)?.boardId === boardId) as + | { id: string } + | undefined, + verify: (rawBody, sig, secret, c) => + verifyTrelloSignature( + rawBody, + buildTrelloCallbackUrl(c.req.header('host'), c.req.header('x-forwarded-proto')), + sig, + secret, + ), +}); - let repoFullName: string | undefined; - try { - // Try JSON first (application/json delivery). - const parsed = JSON.parse(rawBody) as Record; - repoFullName = (parsed?.repository as Record)?.full_name as string | undefined; - } catch { - // Not JSON — try application/x-www-form-urlencoded delivery. - // GitHub sends the payload as `payload=` in that case. +/** + * verifySignature callback for the GitHub webhook handler. + * Returns null to skip verification when no secret is configured (backwards compat). + */ +export const verifyGitHubWebhookSignature = createWebhookVerifier({ + headerName: 'X-Hub-Signature-256', + platform: 'github', + platformLabel: 'GitHub', + extractIdentifier: (_c, rawBody) => { try { + // Try JSON first (application/json delivery). + const parsed = JSON.parse(rawBody) as Record; + const repoFullName = (parsed?.repository as Record)?.full_name as + | string + | undefined; + if (repoFullName) return repoFullName; + } catch { + // Not JSON — try application/x-www-form-urlencoded delivery. + } + try { + // GitHub sends the payload as `payload=` in that case. const payloadStr = new URLSearchParams(rawBody).get('payload'); if (payloadStr) { const parsed = JSON.parse(payloadStr) as Record; - repoFullName = (parsed?.repository as Record)?.full_name as - | string - | undefined; + return (parsed?.repository as Record)?.full_name as string | undefined; } } catch { - // Unparseable body — fall through to the null return below + // Unparseable body — fall through to undefined } - } - - if (!repoFullName) return null; - - const { projects } = await loadProjectConfig(); - const project = projects.find((p) => p.repo === repoFullName); - if (!project) return null; - - const secret = await resolveWebhookSecret(project.id, 'github'); - if (!secret) return null; // No secret configured — skip verification - - if (!signatureHeader) { - return { valid: false, reason: 'Missing signature header' }; - } - - const valid = verifyGitHubSignature(rawBody, signatureHeader, secret); - return valid - ? { valid: true, reason: 'Signature valid' } - : { valid: false, reason: 'GitHub signature mismatch' }; -} + return undefined; + }, + findProject: (repoFullName, projects) => + projects.find((p) => p.repo === repoFullName) as { id: string } | undefined, + verify: (rawBody, sig, secret) => verifyGitHubSignature(rawBody, sig, secret), +}); /** * verifySignature callback for the Sentry webhook handler. @@ -162,27 +223,17 @@ export async function verifyGitHubWebhookSignature( * The project ID is taken from the URL path param (`:projectId`), * which is unambiguous since each Sentry integration gets its own webhook URL. */ -export async function verifySentryWebhookSignature( - c: Context, - rawBody: string, -): Promise<{ valid: boolean; reason: string } | null> { - const signatureHeader = c.req.header('Sentry-Hook-Signature'); - const projectId = c.req.param('projectId'); - - if (!projectId) return null; - - const secret = await resolveWebhookSecret(projectId, 'sentry'); - if (!secret) return null; // No secret configured — skip verification - - if (!signatureHeader) { - return { valid: false, reason: 'Missing Sentry-Hook-Signature header' }; - } - - const valid = verifySentrySignature(rawBody, signatureHeader, secret); - return valid - ? { valid: true, reason: 'Signature valid' } - : { valid: false, reason: 'Sentry signature mismatch' }; -} +export const verifySentryWebhookSignature = createWebhookVerifier({ + headerName: 'Sentry-Hook-Signature', + platform: 'sentry', + platformLabel: 'Sentry', + // Sentry uses the URL path param as its identifier (projectId), not the body. + extractIdentifier: (c, _rawBody) => c.req.param('projectId'), + // For Sentry the identifier IS the project ID — find by direct ID match. + findProject: (projectId, projects) => + projects.find((p) => p.id === projectId) as { id: string } | undefined, + verify: (rawBody, sig, secret) => verifySentrySignature(rawBody, sig, secret), +}); /** * Extract the JIRA project key from a raw webhook payload. @@ -206,28 +257,14 @@ export function extractJiraProjectKey(rawBody: string): string | undefined { * * JIRA Cloud sends the signature as `sha256=` in the `X-Hub-Signature` header. */ -export async function verifyJiraWebhookSignature( - c: Context, - rawBody: string, -): Promise<{ valid: boolean; reason: string } | null> { - const signatureHeader = c.req.header('X-Hub-Signature'); - const jiraProjectKey = extractJiraProjectKey(rawBody); - - if (!jiraProjectKey) return null; - - const { projects } = await loadProjectConfig(); - const project = projects.find((p) => p.jira?.projectKey === jiraProjectKey); - if (!project) return null; - - const secret = await resolveWebhookSecret(project.id, 'jira'); - if (!secret) return null; // No secret configured — skip verification - - if (!signatureHeader) { - return { valid: false, reason: 'Missing signature header' }; - } - - const valid = verifyJiraSignature(rawBody, signatureHeader, secret); - return valid - ? { valid: true, reason: 'Signature valid' } - : { valid: false, reason: 'JIRA signature mismatch' }; -} +export const verifyJiraWebhookSignature = createWebhookVerifier({ + headerName: 'X-Hub-Signature', + platform: 'jira', + platformLabel: 'JIRA', + extractIdentifier: (_c, rawBody) => extractJiraProjectKey(rawBody), + findProject: (jiraProjectKey, projects) => + projects.find( + (p) => (p.jira as Record | undefined)?.projectKey === jiraProjectKey, + ) as { id: string } | undefined, + verify: (rawBody, sig, secret) => verifyJiraSignature(rawBody, sig, secret), +}); diff --git a/src/webhook/signatureVerification.ts b/src/webhook/signatureVerification.ts index cbbb49e4..f20189c9 100644 --- a/src/webhook/signatureVerification.ts +++ b/src/webhook/signatureVerification.ts @@ -7,24 +7,60 @@ import { createHmac, timingSafeEqual } from 'node:crypto'; +// --------------------------------------------------------------------------- +// Generic helper +// --------------------------------------------------------------------------- + /** - * Verify a GitHub webhook signature. + * Options for {@link verifyHmac}. + */ +export interface VerifyHmacOptions { + /** HMAC algorithm, e.g. `'sha256'` or `'sha1'`. */ + algorithm: string; + /** The raw data to sign (e.g. request body, or body + callbackUrl for Trello). */ + data: string; + /** The secret key. */ + secret: string; + /** The signature received from the caller. */ + signature: string; + /** Digest encoding: `'hex'` or `'base64'`. */ + encoding: 'hex' | 'base64'; + /** + * Optional prefix that the computed digest should be wrapped in before + * comparison (e.g. `'sha256='` for GitHub/JIRA). When provided the + * comparison string becomes ``. + */ + prefix?: string; +} + +/** + * Generic timing-safe HMAC verification. * - * GitHub signs payloads with HMAC-SHA256 and sends the result as - * `sha256=` in the `X-Hub-Signature-256` header. + * Computes `HMAC(algorithm, secret).update(data).digest(encoding)`, optionally + * prepends `prefix`, then does a constant-time comparison against `signature`. * - * @param rawBody - The raw request body string. - * @param signature - The value of the `X-Hub-Signature-256` header. - * @param secret - The webhook secret configured in GitHub. - * @returns `true` if the signature is valid, `false` otherwise. + * Returns `false` immediately when `signature` is empty, has the wrong prefix, + * or has a different byte-length than the expected value (short-circuit that + * does not leak timing information about the secret itself). */ -export function verifyGitHubSignature(rawBody: string, signature: string, secret: string): boolean { - if (!signature || !signature.startsWith('sha256=')) { +export function verifyHmac({ + algorithm, + data, + secret, + signature, + encoding, + prefix = '', +}: VerifyHmacOptions): boolean { + if (!signature) { + return false; + } + + if (prefix && !signature.startsWith(prefix)) { return false; } - const expectedHex = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); - const expected = Buffer.from(`sha256=${expectedHex}`, 'utf8'); + const digest = createHmac(algorithm, secret).update(data, 'utf8').digest(encoding); + const expected = Buffer.from(`${prefix}${digest}`, 'utf8'); const actual = Buffer.from(signature, 'utf8'); if (expected.length !== actual.length) { @@ -34,6 +70,32 @@ export function verifyGitHubSignature(rawBody: string, signature: string, secret return timingSafeEqual(expected, actual); } +// --------------------------------------------------------------------------- +// Per-platform public API (unchanged signatures) +// --------------------------------------------------------------------------- + +/** + * Verify a GitHub webhook signature. + * + * GitHub signs payloads with HMAC-SHA256 and sends the result as + * `sha256=` in the `X-Hub-Signature-256` header. + * + * @param rawBody - The raw request body string. + * @param signature - The value of the `X-Hub-Signature-256` header. + * @param secret - The webhook secret configured in GitHub. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyGitHubSignature(rawBody: string, signature: string, secret: string): boolean { + return verifyHmac({ + algorithm: 'sha256', + data: rawBody, + secret, + signature, + encoding: 'hex', + prefix: 'sha256=', + }); +} + /** * Verify a Trello webhook signature. * @@ -52,22 +114,13 @@ export function verifyTrelloSignature( signature: string, secret: string, ): boolean { - if (!signature) { - return false; - } - - const expectedBase64 = createHmac('sha1', secret) - .update(rawBody + callbackUrl, 'utf8') - .digest('base64'); - - const expected = Buffer.from(expectedBase64, 'utf8'); - const actual = Buffer.from(signature, 'utf8'); - - if (expected.length !== actual.length) { - return false; - } - - return timingSafeEqual(expected, actual); + return verifyHmac({ + algorithm: 'sha1', + data: rawBody + callbackUrl, + secret, + signature, + encoding: 'base64', + }); } /** @@ -82,19 +135,13 @@ export function verifyTrelloSignature( * @returns `true` if the signature is valid, `false` otherwise. */ export function verifySentrySignature(rawBody: string, signature: string, secret: string): boolean { - if (!signature) { - return false; - } - - const expectedHex = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); - const expected = Buffer.from(expectedHex, 'utf8'); - const actual = Buffer.from(signature, 'utf8'); - - if (expected.length !== actual.length) { - return false; - } - - return timingSafeEqual(expected, actual); + return verifyHmac({ + algorithm: 'sha256', + data: rawBody, + secret, + signature, + encoding: 'hex', + }); } /** @@ -109,17 +156,12 @@ export function verifySentrySignature(rawBody: string, signature: string, secret * @returns `true` if the signature is valid, `false` otherwise. */ export function verifyJiraSignature(rawBody: string, signature: string, secret: string): boolean { - if (!signature || !signature.startsWith('sha256=')) { - return false; - } - - const expectedHex = createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex'); - const expected = Buffer.from(`sha256=${expectedHex}`, 'utf8'); - const actual = Buffer.from(signature, 'utf8'); - - if (expected.length !== actual.length) { - return false; - } - - return timingSafeEqual(expected, actual); + return verifyHmac({ + algorithm: 'sha256', + data: rawBody, + secret, + signature, + encoding: 'hex', + prefix: 'sha256=', + }); } diff --git a/tests/unit/router/webhook-signature.test.ts b/tests/unit/router/webhook-signature.test.ts index d3fe54ae..d1388277 100644 --- a/tests/unit/router/webhook-signature.test.ts +++ b/tests/unit/router/webhook-signature.test.ts @@ -125,6 +125,7 @@ import { loadProjectConfig, routerConfig } from '../../../src/router/config.js'; import { resolveWebhookSecret } from '../../../src/router/platformClients/credentials.js'; import { buildTrelloCallbackUrl, + createWebhookVerifier, extractJiraProjectKey, extractTrelloBoardId, verifyGitHubWebhookSignature, @@ -896,3 +897,101 @@ describe('router — JIRA webhook signature verification (end-to-end)', () => { ); }); }); + +// --------------------------------------------------------------------------- +// Unit tests: createWebhookVerifier factory +// --------------------------------------------------------------------------- + +describe('createWebhookVerifier factory', () => { + const PROJECT = { id: 'proj-factory', repo: 'owner/factory-repo' }; + const SECRET = 'factory-test-secret'; + const IDENTIFIER = 'factory-id'; + const HEADER_NAME = 'X-Test-Signature'; + + function makeContext(headers: Record = {}, params: Record = {}) { + return { + req: { + header: (name: string) => headers[name.toLowerCase()] ?? headers[name], + param: (name: string) => params[name], + }, + } as unknown as import('hono').Context; + } + + const mockVerify = vi.fn<[string, string, string], boolean>(); + const mockExtract = vi.fn<[import('hono').Context, string], string | undefined>(); + const mockFind = vi.fn<[string, Array>], { id: string } | undefined>(); + + const verifier = createWebhookVerifier({ + headerName: HEADER_NAME, + platform: 'test-platform', + platformLabel: 'TestPlatform', + extractIdentifier: mockExtract, + findProject: mockFind, + verify: mockVerify, + }); + + beforeEach(() => { + vi.mocked(loadProjectConfig).mockResolvedValue({ projects: [PROJECT] }); + vi.mocked(resolveWebhookSecret).mockResolvedValue(SECRET); + mockExtract.mockReturnValue(IDENTIFIER); + mockFind.mockReturnValue(PROJECT); + mockVerify.mockReturnValue(true); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns { valid: true } when all conditions are met and verify returns true', async () => { + const result = await verifier(makeContext({ [HEADER_NAME]: 'valid-sig' }), 'body'); + expect(result).toEqual({ valid: true, reason: 'Signature valid' }); + expect(mockVerify).toHaveBeenCalledWith('body', 'valid-sig', SECRET, expect.anything()); + }); + + it('returns { valid: false, reason: "TestPlatform signature mismatch" } when verify returns false', async () => { + mockVerify.mockReturnValue(false); + const result = await verifier(makeContext({ [HEADER_NAME]: 'bad-sig' }), 'body'); + expect(result).toEqual({ valid: false, reason: 'TestPlatform signature mismatch' }); + }); + + it('returns null when extractIdentifier returns undefined', async () => { + mockExtract.mockReturnValue(undefined); + const result = await verifier(makeContext({ [HEADER_NAME]: 'sig' }), 'body'); + expect(result).toBeNull(); + }); + + it('returns null when findProject returns undefined (no matching project)', async () => { + mockFind.mockReturnValue(undefined); + const result = await verifier(makeContext({ [HEADER_NAME]: 'sig' }), 'body'); + expect(result).toBeNull(); + }); + + it('returns null when resolveWebhookSecret returns null (no secret configured)', async () => { + vi.mocked(resolveWebhookSecret).mockResolvedValue(null); + const result = await verifier(makeContext({ [HEADER_NAME]: 'sig' }), 'body'); + expect(result).toBeNull(); + }); + + it('returns { valid: false, reason: "Missing signature header" } when header is absent but secret is set', async () => { + const result = await verifier(makeContext({}), 'body'); + expect(result).toEqual({ valid: false, reason: 'Missing signature header' }); + }); + + it('passes the Hono context to the verify function (needed for Trello callback URL)', async () => { + const ctx = makeContext({ [HEADER_NAME]: 'sig' }); + await verifier(ctx, 'body'); + // The 4th argument to verify should be the Hono context object + expect(mockVerify).toHaveBeenCalledWith('body', 'sig', SECRET, ctx); + }); + + it('passes the Hono context to extractIdentifier', async () => { + const ctx = makeContext({ [HEADER_NAME]: 'sig' }); + await verifier(ctx, 'body'); + expect(mockExtract).toHaveBeenCalledWith(ctx, 'body'); + }); + + it('passes the extracted identifier and projects array to findProject', async () => { + await verifier(makeContext({ [HEADER_NAME]: 'sig' }), 'body'); + expect(mockFind).toHaveBeenCalledWith(IDENTIFIER, expect.any(Array)); + }); +}); diff --git a/tests/unit/webhook/signatureVerification.test.ts b/tests/unit/webhook/signatureVerification.test.ts index 5e74b932..7462e51c 100644 --- a/tests/unit/webhook/signatureVerification.test.ts +++ b/tests/unit/webhook/signatureVerification.test.ts @@ -2,6 +2,7 @@ import { createHmac } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { verifyGitHubSignature, + verifyHmac, verifyJiraSignature, verifySentrySignature, verifyTrelloSignature, @@ -244,3 +245,125 @@ describe('verifySentrySignature', () => { expect(verifySentrySignature(body, 'abc', secret)).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// verifyHmac — generic helper (edge cases) +// --------------------------------------------------------------------------- + +describe('verifyHmac', () => { + const secret = 'test-secret'; + const body = 'hello world'; + + // Helper: compute a valid HMAC for the test body using sha256/hex/no-prefix + function hmacHex(data: string, s: string): string { + return createHmac('sha256', s).update(data, 'utf8').digest('hex'); + } + + it('returns true for a valid sha256/hex/no-prefix signature', () => { + const sig = hmacHex(body, secret); + expect( + verifyHmac({ algorithm: 'sha256', data: body, secret, signature: sig, encoding: 'hex' }), + ).toBe(true); + }); + + it('returns true for a valid sha256/hex/prefix signature', () => { + const sig = `sha256=${hmacHex(body, secret)}`; + expect( + verifyHmac({ + algorithm: 'sha256', + data: body, + secret, + signature: sig, + encoding: 'hex', + prefix: 'sha256=', + }), + ).toBe(true); + }); + + it('returns false when signature is an empty string', () => { + expect( + verifyHmac({ algorithm: 'sha256', data: body, secret, signature: '', encoding: 'hex' }), + ).toBe(false); + }); + + it('returns false when prefix is required but missing from signature', () => { + // Raw hex without the sha256= prefix + const rawHex = hmacHex(body, secret); + expect( + verifyHmac({ + algorithm: 'sha256', + data: body, + secret, + signature: rawHex, + encoding: 'hex', + prefix: 'sha256=', + }), + ).toBe(false); + }); + + it('returns false when prefix is wrong (sha1= instead of sha256=)', () => { + const wrongPrefix = `sha1=${hmacHex(body, secret)}`; + expect( + verifyHmac({ + algorithm: 'sha256', + data: body, + secret, + signature: wrongPrefix, + encoding: 'hex', + prefix: 'sha256=', + }), + ).toBe(false); + }); + + it('returns false when length differs (correctly-prefixed but truncated hex)', () => { + expect( + verifyHmac({ + algorithm: 'sha256', + data: body, + secret, + signature: 'sha256=abc', + encoding: 'hex', + prefix: 'sha256=', + }), + ).toBe(false); + }); + + it('returns false when the secret is wrong', () => { + const sig = hmacHex(body, 'wrong-secret'); + expect( + verifyHmac({ algorithm: 'sha256', data: body, secret, signature: sig, encoding: 'hex' }), + ).toBe(false); + }); + + it('returns false when the data has been tampered with', () => { + const sig = hmacHex(body, secret); + expect( + verifyHmac({ + algorithm: 'sha256', + data: `${body}tampered`, + secret, + signature: sig, + encoding: 'hex', + }), + ).toBe(false); + }); + + it('supports sha1/base64 (Trello algorithm)', () => { + const sig = createHmac('sha1', secret).update(body, 'utf8').digest('base64'); + expect( + verifyHmac({ algorithm: 'sha1', data: body, secret, signature: sig, encoding: 'base64' }), + ).toBe(true); + }); + + it('returns false for a completely garbage signature string', () => { + expect( + verifyHmac({ + algorithm: 'sha256', + data: body, + secret, + signature: 'not-a-signature!', + encoding: 'hex', + }), + ).toBe(false); + }); +});