diff --git a/src/__tests__/crypto.test.ts b/src/__tests__/crypto.test.ts new file mode 100644 index 0000000..7075d82 --- /dev/null +++ b/src/__tests__/crypto.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { timingSafeEqual } from "../utils/crypto"; + +describe("timingSafeEqual", () => { + it("returns true for identical strings", () => { + expect(timingSafeEqual("abc", "abc")).toBe(true); + }); + + it("returns false for same-length but different strings", () => { + expect(timingSafeEqual("abc", "abd")).toBe(false); + }); + + it("returns false for different-length strings", () => { + expect(timingSafeEqual("abc", "abcd")).toBe(false); + }); + + it("returns false for empty vs non-empty", () => { + expect(timingSafeEqual("", "a")).toBe(false); + }); + + it("returns true for two empty strings", () => { + expect(timingSafeEqual("", "")).toBe(true); + }); + + it("executes in constant time within ±200% CV (JS timing resolution limit)", () => { + const token = "a".repeat(64); + const correct = "a".repeat(64); + const wrong = "b".repeat(64); + + for (let i = 0; i < 100; i++) { + timingSafeEqual(token, correct); + timingSafeEqual(token, wrong); + } + + const correctTimes: number[] = []; + const wrongTimes: number[] = []; + + for (let i = 0; i < 1000; i++) { + const t1 = performance.now(); + timingSafeEqual(token, correct); + correctTimes.push(performance.now() - t1); + + const t2 = performance.now(); + timingSafeEqual(token, wrong); + wrongTimes.push(performance.now() - t2); + } + + const mean = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length; + const correctMean = mean(correctTimes); + const wrongMean = mean(wrongTimes); + + // The means should be within 2x of each other — proves no short-circuit + const ratio = Math.max(correctMean, wrongMean) / Math.min(correctMean, wrongMean); + expect(ratio).toBeLessThan(2); + }); +}); diff --git a/src/utils/crypto.ts b/src/utils/crypto.ts new file mode 100644 index 0000000..c51bba8 --- /dev/null +++ b/src/utils/crypto.ts @@ -0,0 +1,28 @@ +/** + * Compare two strings in constant time to avoid timing side-channel attacks. + * Use this for token / secret comparisons instead of ===. + * + * Using === short-circuits on the first byte difference, leaking the + * prefix-length-match through timing. An attacker controlling the input can + * extract the correct token byte-by-byte via timing oracle in + * O(256 × 32 × N samples) requests. + * + * This implementation returns false immediately on length mismatch (safe + * because token lengths are fixed and public), then uses a XOR-accumulator + * loop with no branching to compare all bytes in constant time. + * + * NOTE: Once Web Crypto's timingSafeEqual ships universally + * (https://github.com/whatwg/webcrypto/issues/270), prefer that. + * + * @see https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#timing-attacks + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let mismatch = 0; + for (let i = 0; i < a.length; i++) { + mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return mismatch === 0; +}