Skip to content

Commit f493e23

Browse files
authored
fix(security): add timing-safe token comparison helper (#43)
Adds timingSafeEqual utility for constant-time string comparison to prevent timing side-channel attacks on MCP pairing tokens.
1 parent 33a4323 commit f493e23

2 files changed

Lines changed: 84 additions & 0 deletions

File tree

src/__tests__/crypto.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { timingSafeEqual } from "../utils/crypto";
3+
4+
describe("timingSafeEqual", () => {
5+
it("returns true for identical strings", () => {
6+
expect(timingSafeEqual("abc", "abc")).toBe(true);
7+
});
8+
9+
it("returns false for same-length but different strings", () => {
10+
expect(timingSafeEqual("abc", "abd")).toBe(false);
11+
});
12+
13+
it("returns false for different-length strings", () => {
14+
expect(timingSafeEqual("abc", "abcd")).toBe(false);
15+
});
16+
17+
it("returns false for empty vs non-empty", () => {
18+
expect(timingSafeEqual("", "a")).toBe(false);
19+
});
20+
21+
it("returns true for two empty strings", () => {
22+
expect(timingSafeEqual("", "")).toBe(true);
23+
});
24+
25+
it("executes in constant time within ±200% CV (JS timing resolution limit)", () => {
26+
const token = "a".repeat(64);
27+
const correct = "a".repeat(64);
28+
const wrong = "b".repeat(64);
29+
30+
for (let i = 0; i < 100; i++) {
31+
timingSafeEqual(token, correct);
32+
timingSafeEqual(token, wrong);
33+
}
34+
35+
const correctTimes: number[] = [];
36+
const wrongTimes: number[] = [];
37+
38+
for (let i = 0; i < 1000; i++) {
39+
const t1 = performance.now();
40+
timingSafeEqual(token, correct);
41+
correctTimes.push(performance.now() - t1);
42+
43+
const t2 = performance.now();
44+
timingSafeEqual(token, wrong);
45+
wrongTimes.push(performance.now() - t2);
46+
}
47+
48+
const mean = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
49+
const correctMean = mean(correctTimes);
50+
const wrongMean = mean(wrongTimes);
51+
52+
// The means should be within 2x of each other — proves no short-circuit
53+
const ratio = Math.max(correctMean, wrongMean) / Math.min(correctMean, wrongMean);
54+
expect(ratio).toBeLessThan(2);
55+
});
56+
});

src/utils/crypto.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Compare two strings in constant time to avoid timing side-channel attacks.
3+
* Use this for token / secret comparisons instead of ===.
4+
*
5+
* Using === short-circuits on the first byte difference, leaking the
6+
* prefix-length-match through timing. An attacker controlling the input can
7+
* extract the correct token byte-by-byte via timing oracle in
8+
* O(256 × 32 × N samples) requests.
9+
*
10+
* This implementation returns false immediately on length mismatch (safe
11+
* because token lengths are fixed and public), then uses a XOR-accumulator
12+
* loop with no branching to compare all bytes in constant time.
13+
*
14+
* NOTE: Once Web Crypto's timingSafeEqual ships universally
15+
* (https://github.com/whatwg/webcrypto/issues/270), prefer that.
16+
*
17+
* @see https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html#timing-attacks
18+
*/
19+
export function timingSafeEqual(a: string, b: string): boolean {
20+
if (a.length !== b.length) {
21+
return false;
22+
}
23+
let mismatch = 0;
24+
for (let i = 0; i < a.length; i++) {
25+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
26+
}
27+
return mismatch === 0;
28+
}

0 commit comments

Comments
 (0)