diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts index 13e2e66372..1b98f2e7be 100644 --- a/src/utils/crypto.ts +++ b/src/utils/crypto.ts @@ -24,6 +24,7 @@ export async function verifyGitHubSignature(rawBody: string, signatureHeader: st export function timingSafeEqualHex(left: string, right: string): boolean { const leftBytes = hexToBytes(left); const rightBytes = hexToBytes(right); + if (leftBytes === null || rightBytes === null) return false; if (leftBytes.length !== rightBytes.length) return false; let result = 0; for (let index = 0; index < leftBytes.length; index += 1) { @@ -32,11 +33,13 @@ export function timingSafeEqualHex(left: string, right: string): boolean { return result === 0; } -function hexToBytes(hex: string): Uint8Array { - if (!/^[0-9a-f]+$/i.test(hex) || hex.length % 2 !== 0) return new Uint8Array(); +function hexToBytes(hex: string): Uint8Array | null { + if (hex.length === 0 || hex.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let index = 0; index < bytes.length; index += 1) { - bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + const byte = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); + if (Number.isNaN(byte)) return null; + bytes[index] = byte; } return bytes; } diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index 76ea5d8129..1ea9b947c9 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -1,6 +1,35 @@ import { describe, expect, it } from "vitest"; import { createOpaqueToken, hashToken, timingSafeEqual } from "../../src/auth/security"; -import { verifyGitHubSignature } from "../../src/utils/crypto"; +import { base64UrlEncode, sha256Hex, timingSafeEqualHex, verifyGitHubSignature } from "../../src/utils/crypto"; + +describe("crypto helpers", () => { + it("hashes input with sha256Hex", async () => { + const digest = await sha256Hex("gittensory"); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + expect(digest).toBe(await sha256Hex("gittensory")); + expect(digest).not.toBe(await sha256Hex("gittensory-x")); + }); + + it("compares hex strings in constant time and rejects malformed input", () => { + expect(timingSafeEqualHex("ab12", "ab12")).toBe(true); + expect(timingSafeEqualHex("AB12", "ab12")).toBe(true); + expect(timingSafeEqualHex("ab12", "ab13")).toBe(false); + expect(timingSafeEqualHex("ab12", "ab1234")).toBe(false); + expect(timingSafeEqualHex("zz", "00")).toBe(false); + expect(timingSafeEqualHex("gg", "gg")).toBe(false); + expect(timingSafeEqualHex("abc", "def")).toBe(false); + expect(timingSafeEqualHex("", "00")).toBe(false); + expect(timingSafeEqualHex("00", "")).toBe(false); + expect(timingSafeEqualHex("0x12", "0012")).toBe(false); + expect(timingSafeEqualHex("12gh", "12gh")).toBe(false); + }); + + it("base64url-encodes strings and byte arrays without padding", () => { + expect(base64UrlEncode("hello")).toBe("aGVsbG8"); + expect(base64UrlEncode(new Uint8Array([255, 254]))).toBe("__4"); + expect(base64UrlEncode("subjects?_d")).toBe(base64UrlEncode("subjects?_d")); + }); +}); describe("webhook signature verification", () => { it("accepts valid GitHub HMAC signatures and rejects tampering", async () => { @@ -17,6 +46,11 @@ describe("webhook signature verification", () => { await expect(verifyGitHubSignature(body, null, secret)).resolves.toBe(false); await expect(verifyGitHubSignature(body, "bad-prefix", secret)).resolves.toBe(false); await expect(verifyGitHubSignature(body, `sha256=${signature}`, "")).resolves.toBe(false); + await expect(verifyGitHubSignature(body, "sha256=abc", secret)).resolves.toBe(false); + await expect(verifyGitHubSignature(body, "sha256=zz", secret)).resolves.toBe(false); + await expect( + verifyGitHubSignature(body, `sha256=${"a".repeat(63)}`, secret), + ).resolves.toBe(false); }); it("uses timing-safe token comparisons and one-way token hashes", async () => {