From c15a785d9ef9204836e22941c403f21b99119021 Mon Sep 17 00:00:00 2001 From: jaso0n0818 <277638856+jaso0n0818@users.noreply.github.com> Date: Sun, 14 Jun 2026 09:12:25 -0600 Subject: [PATCH 1/4] test(crypto): cover sha256, hex compare, and base64url helpers --- test/unit/crypto.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index 76ea5d8129..06ad039405 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -1,6 +1,29 @@ 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", "ab13")).toBe(false); + expect(timingSafeEqualHex("ab12", "ab1234")).toBe(false); + expect(timingSafeEqualHex("zz", "00")).toBe(false); + expect(timingSafeEqualHex("abc", "def")).toBe(true); + }); + + 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 () => { From 2ad8d5969409731ecc625ade14e9a3f726d28003 Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Thu, 18 Jun 2026 15:01:57 +0000 Subject: [PATCH 2/4] fix(crypto): reject malformed hex in timingSafeEqualHex Fail closed when either operand is empty, odd-length, or non-hex so unequal malformed digests cannot compare equal (webhook verification). Updates tests for sha256/base64url helpers accordingly. Co-authored-by: Cursor --- src/utils/crypto.ts | 9 ++++++--- test/unit/crypto.test.ts | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) 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 06ad039405..3de33f0199 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -15,7 +15,8 @@ describe("crypto helpers", () => { expect(timingSafeEqualHex("ab12", "ab13")).toBe(false); expect(timingSafeEqualHex("ab12", "ab1234")).toBe(false); expect(timingSafeEqualHex("zz", "00")).toBe(false); - expect(timingSafeEqualHex("abc", "def")).toBe(true); + expect(timingSafeEqualHex("abc", "def")).toBe(false); + expect(timingSafeEqualHex("", "00")).toBe(false); }); it("base64url-encodes strings and byte arrays without padding", () => { @@ -40,6 +41,8 @@ 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); }); it("uses timing-safe token comparisons and one-way token hashes", async () => { From d49d8ee4b74a86b959e80dc770b76403294ed53f Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Thu, 18 Jun 2026 15:07:17 +0000 Subject: [PATCH 3/4] test(crypto): broaden malformed hex coverage Co-authored-by: Cursor --- test/unit/crypto.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index 3de33f0199..f0edacdf34 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -12,11 +12,14 @@ describe("crypto helpers", () => { 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); }); it("base64url-encodes strings and byte arrays without padding", () => { From f064a4d1c74a8ee9ff1b40958f26490c54811e13 Mon Sep 17 00:00:00 2001 From: jaso0n0818 Date: Thu, 18 Jun 2026 15:22:47 +0000 Subject: [PATCH 4/4] test(crypto): cover non-hex and odd-length webhook digests Co-authored-by: Cursor --- test/unit/crypto.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/unit/crypto.test.ts b/test/unit/crypto.test.ts index f0edacdf34..1ea9b947c9 100644 --- a/test/unit/crypto.test.ts +++ b/test/unit/crypto.test.ts @@ -20,6 +20,8 @@ describe("crypto helpers", () => { 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", () => { @@ -46,6 +48,9 @@ describe("webhook signature verification", () => { 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 () => {