Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/__tests__/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
28 changes: 28 additions & 0 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Comment on lines +19 to +28

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Security & Performance Issues

  1. CPU Denial of Service (DoS): If an attacker passes an extremely large string as one of the arguments, maxLen will be huge, and the loop will run millions of times. This blocks the single-threaded Node.js event loop, creating an easy vector for CPU exhaustion/DoS attacks.
  2. JIT Branching Timing Leak: The ternary operators i < a.length ? a.charCodeAt(i) : 0 introduce conditional branching. Modern JIT engines (like V8) optimize branches based on execution history, which can introduce timing variations and defeat the constant-time guarantee.

Recommended Solution

Return false immediately if the lengths of a and b are different. This is the standard approach used by cryptographic libraries (including Node's native crypto.timingSafeEqual and Web Crypto API / standard comparison helpers). Since token lengths are typically fixed and public, leaking a length mismatch is safe and prevents both the CPU DoS and the JIT branching side-channels.

Suggested change
export function timingSafeEqual(a: string, b: string): boolean {
const maxLen = Math.max(a.length, b.length);
let mismatch = a.length !== b.length ? 1 : 0;
for (let i = 0; i < maxLen; i++) {
const ca = i < a.length ? a.charCodeAt(i) : 0;
const cb = i < b.length ? b.charCodeAt(i) : 0;
mismatch |= (ca ^ cb);
}
return mismatch === 0;
}
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;
}

Loading