From 18cb424b99812f5dfc8903154f670a3c5f72db6b Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Sat, 11 Jul 2026 14:02:28 -0400 Subject: [PATCH] feat(webhooks): add GitHub webhook signature verifier --- .../backend/convex/githubWebhookVerify.ts | 38 ++++++++++ .../tests/unit/github-webhook-verify.test.ts | 73 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 packages/backend/convex/githubWebhookVerify.ts create mode 100644 packages/backend/tests/unit/github-webhook-verify.test.ts diff --git a/packages/backend/convex/githubWebhookVerify.ts b/packages/backend/convex/githubWebhookVerify.ts new file mode 100644 index 0000000..175ffac --- /dev/null +++ b/packages/backend/convex/githubWebhookVerify.ts @@ -0,0 +1,38 @@ +const toHex = (buffer: ArrayBuffer): string => + Array.from(new Uint8Array(buffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + +export const timingSafeEqualHex = (a: string, b: string): boolean => { + if (a.length !== b.length) return false; + let mismatch = 0; + for (let i = 0; i < a.length; i += 1) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +}; + +export const verifyGithubSignature = async ( + secret: string, + rawBody: string, + signatureHeader: string | null, +): Promise => { + if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false; + // Web Crypto's importKey throws on a zero-length HMAC key; guard so the + // verifier never throws on attacker-controlled input (returns false instead). + if (!secret) return false; + const provided = signatureHeader.slice('sha256='.length); + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const mac = await crypto.subtle.sign( + 'HMAC', + key, + new TextEncoder().encode(rawBody), + ); + return timingSafeEqualHex(toHex(mac), provided); +}; diff --git a/packages/backend/tests/unit/github-webhook-verify.test.ts b/packages/backend/tests/unit/github-webhook-verify.test.ts new file mode 100644 index 0000000..b51458d --- /dev/null +++ b/packages/backend/tests/unit/github-webhook-verify.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'vitest'; + +import { + timingSafeEqualHex, + verifyGithubSignature, +} from '../../convex/githubWebhookVerify'; + +const SECRET = "It's a Secret to Everybody"; +const BODY = 'Hello, World!'; +const VALID_SIGNATURE = + 'sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17'; + +describe('verifyGithubSignature', () => { + test('verifies the GitHub known vector', async () => { + expect(await verifyGithubSignature(SECRET, BODY, VALID_SIGNATURE)).toBe( + true, + ); + }); + + test('rejects a tampered body', async () => { + expect( + await verifyGithubSignature(SECRET, 'Hello, World?', VALID_SIGNATURE), + ).toBe(false); + }); + + test('rejects a wrong secret', async () => { + expect( + await verifyGithubSignature('wrong secret', BODY, VALID_SIGNATURE), + ).toBe(false); + }); + + test('rejects a header missing the sha256= prefix', async () => { + expect( + await verifyGithubSignature( + SECRET, + BODY, + '757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17', + ), + ).toBe(false); + }); + + test('rejects a null header', async () => { + expect(await verifyGithubSignature(SECRET, BODY, null)).toBe(false); + }); + + test('rejects an empty header', async () => { + expect(await verifyGithubSignature(SECRET, BODY, '')).toBe(false); + }); + + test('rejects a correct-format but wrong-length signature', async () => { + expect( + await verifyGithubSignature(SECRET, BODY, 'sha256=deadbeef'), + ).toBe(false); + }); + + test('rejects an empty secret', async () => { + expect(await verifyGithubSignature('', BODY, VALID_SIGNATURE)).toBe(false); + }); +}); + +describe('timingSafeEqualHex', () => { + test('returns true for equal strings', () => { + expect(timingSafeEqualHex('abcd', 'abcd')).toBe(true); + }); + + test('returns false for equal-length but differing strings', () => { + expect(timingSafeEqualHex('abcd', 'abce')).toBe(false); + }); + + test('returns false for different lengths', () => { + expect(timingSafeEqualHex('abcd', 'abcde')).toBe(false); + }); +});