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
13 changes: 8 additions & 5 deletions src/utils/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ export function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) {
// Pad the shorter buffer to match the longer one so timingSafeEqual
// doesn't throw. The length mismatch itself is not secret — an
// attacker learns nothing from it that they couldn't already see by
// comparing the strings directly.
// Buffers of different lengths are never equal. We still run a
// constant-time comparison (on zero-padded copies) so the function's
// timing doesn't leak the fact that lengths differ, but the result is
// always `false` here — without this explicit check, zero-padding
// would make strings that only differ by trailing NUL bytes (e.g.
// "abc" vs "abc\0") compare as equal, which is not the intent.
const padded = Buffer.alloc(Math.max(bufA.length, bufB.length));
bufA.copy(padded);
const paddedB = Buffer.alloc(padded.length);
bufB.copy(paddedB);
return crypto.timingSafeEqual(padded, paddedB);
crypto.timingSafeEqual(padded, paddedB);
return false;
}
return crypto.timingSafeEqual(bufA, bufB);
}
66 changes: 66 additions & 0 deletions tests/unit/utils/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { sha256Hash, safeEqual } from "../../../src/utils/crypto.js";

describe("sha256Hash", () => {
it("returns a 64-character lowercase hex string", () => {
const hash = sha256Hash("hello world");
expect(hash).toMatch(/^[0-9a-f]{64}$/);
});

it("is deterministic for the same input", () => {
expect(sha256Hash("same input")).toBe(sha256Hash("same input"));
});

it("produces different hashes for different inputs", () => {
expect(sha256Hash("input-a")).not.toBe(sha256Hash("input-b"));
});

it("matches the known SHA-256 digest for an empty string", () => {
expect(sha256Hash("")).toBe(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
);
});

it("hashes unicode input without throwing", () => {
expect(() => sha256Hash("ünïcode 🚀")).not.toThrow();
expect(sha256Hash("ünïcode 🚀")).toMatch(/^[0-9a-f]{64}$/);
});
});

describe("safeEqual", () => {
it("returns true for identical strings", () => {
expect(safeEqual("secret-token", "secret-token")).toBe(true);
});

it("returns false for different strings of the same length", () => {
expect(safeEqual("secret-token", "secret-tokeX")).toBe(false);
});

it("returns false for strings of different lengths", () => {
expect(safeEqual("short", "a-much-longer-string")).toBe(false);
});

it("returns false when one string is a prefix of the other", () => {
expect(safeEqual("abc", "abcd")).toBe(false);
});

it("returns false for strings that only differ by trailing NUL bytes", () => {
// Regression guard: zero-padding the shorter buffer for the
// constant-time comparison must not make "abc" and "abc\0" compare
// equal just because their padded buffers happen to match.
expect(safeEqual("abc", "abc\0")).toBe(false);
expect(safeEqual("abc\0", "abc")).toBe(false);
});

it("returns true for two empty strings", () => {
expect(safeEqual("", "")).toBe(true);
});

it("returns false comparing an empty string to a non-empty one", () => {
expect(safeEqual("", "a")).toBe(false);
});

it("is case-sensitive", () => {
expect(safeEqual("Secret", "secret")).toBe(false);
});
});