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
2 changes: 1 addition & 1 deletion scratch/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const testDir = path.resolve(process.cwd(), 'tests');
const nodeBin = process.execPath;
const jitiCli = path.resolve(process.cwd(), 'node_modules/jiti/lib/jiti-cli.mjs');
const vitestCli = path.resolve(process.cwd(), 'node_modules/vitest/vitest.mjs');
const vitestTests = new Set(['elo-rating.test.ts']);
const vitestTests = new Set(['constant-time.test.ts', 'elo-rating.test.ts']);

// Authoritative offline unit, domain, security, and integration test suites
const liveDbTests = new Set([
Expand Down
74 changes: 74 additions & 0 deletions src/lib/security/constant-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Comparison helpers that avoid early exits based on differing byte values.
*
* These are best-effort JavaScript implementations, not a cryptographic timing
* guarantee: JIT compilation, allocation, Unicode normalization, and encoding
* can vary in cost. Input lengths are not hidden. Use native cryptographic
* verification APIs where a strict timing-security boundary is required.
*/

/** Controls canonical Unicode normalization before UTF-8 comparison. */
export interface TimingSafeCompareOptions {
/** Apply NFC normalization before encoding. Defaults to true. */
normalizeUnicode?: boolean;
}

/**
* Compare every byte of the first buffer without exiting on a differing byte.
* For unequal lengths, compare against a zero-filled dummy of the first buffer's
* length and still return false. Work scales with a.length; unequal-length
* allocation and the surrounding runtime are not guaranteed timing-safe.
* Neither input is modified, and typed-array view offsets are respected.
*/
export function timingSafeBufferEqual(a: Uint8Array, b: Uint8Array): boolean {
const lenDiff = Number(a.length !== b.length);
const comparison = lenDiff === 0 ? b : new Uint8Array(a.length);
let diff = 0;

for (let i = 0; i < a.length; i++) {
diff |= a[i] ^ comparison[i];
}

return diff === 0 && lenDiff === 0;
}

/**
* Compare NFC-normalized UTF-8 strings by accumulating byte differences.
* Set normalizeUnicode to false to compare their unnormalized UTF-8 encodings.
* TextEncoder replaces unpaired UTF-16 surrogates with U+FFFD, so this compares
* encoded text rather than exact UTF-16 code units for malformed strings.
*/
export function constantTimeEqual(
a: string,
b: string,
options: TimingSafeCompareOptions = {},
): boolean {
const { normalizeUnicode = true } = options;
const encoder = new TextEncoder();
const bufA = encoder.encode(normalizeUnicode ? a.normalize('NFC') : a);
const bufB = encoder.encode(normalizeUnicode ? b.normalize('NFC') : b);
return timingSafeBufferEqual(bufA, bufB);
}

/**
* Select a string using bit masks rather than a condition-dependent branch.
* Visits both candidates up to their maximum UTF-16 length and preserves the
* selected string exactly, including null characters and unpaired surrogates.
* The returned length is observable; slicing and JS execution are not guaranteed
* constant-time. This does not conceal the condition from a returned value.
*/
export function timingSafeSelect(condition: boolean, ifTrue: string, ifFalse: string): string {
const mask = -Number(condition);
const length = Math.max(ifTrue.length, ifFalse.length);
let selected = '';

for (let i = 0; i < length; i++) {
// Out-of-range charCodeAt returns NaN; bitwise conversion pads it with zero.
const trueCode = ifTrue.charCodeAt(i) | 0;
const falseCode = ifFalse.charCodeAt(i) | 0;
selected += String.fromCharCode((trueCode & mask) | (falseCode & ~mask));
}

const selectedLength = (ifTrue.length & mask) | (ifFalse.length & ~mask);
return selected.slice(0, selectedLength);
}
131 changes: 131 additions & 0 deletions tests/constant-time.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import {
constantTimeEqual,
timingSafeBufferEqual,
timingSafeSelect,
} from '../src/lib/security/constant-time';

describe('constantTimeEqual', () => {
it.each([
['invite-token', 'invite-token', true],
['invite-token', 'Invite-token', false],
['invite-token', 'invite-tokeN', false],
['invite-token', 'invite-Xoken', false],
['token', 'token-longer', false],
['token-longer', 'token', false],
['', '', true],
['', 'a', false],
['a', '', false],
['\0', '', false],
['a\0b', 'a\0b', true],
['a\0b', 'a\0c', false],
['你好🔒', '你好🔒', true],
['你好🔒', '你好🔓', false],
['é', 'aa', false],
['é', 'e', false],
])('compares %j and %j as %s', (a, b, expected) => {
expect(constantTimeEqual(a, b)).toBe(expected);
});

it('defaults to NFC normalization, including empty options', () => {
expect(constantTimeEqual('café', 'cafe\u0301')).toBe(true);
expect(constantTimeEqual('cafe\u0301', 'café', {})).toBe(true);
expect(constantTimeEqual('\u212B', 'A\u030A', { normalizeUnicode: true })).toBe(true);
});

it('can disable normalization without changing identical text', () => {
expect(constantTimeEqual('café', 'cafe\u0301', { normalizeUnicode: false })).toBe(false);
expect(constantTimeEqual('cafe\u0301', 'cafe\u0301', { normalizeUnicode: false })).toBe(true);
});

it('does not apply compatibility normalization, case folding, or trimming', () => {
expect(constantTimeEqual('A', 'A')).toBe(false);
expect(constantTimeEqual('PIN', 'pin')).toBe(false);
expect(constantTimeEqual('pin ', 'pin')).toBe(false);
});

it('follows TextEncoder replacement semantics for unpaired surrogates', () => {
expect(constantTimeEqual('\uD800', '\uFFFD')).toBe(true);
expect(constantTimeEqual('\uD800', '\uDC00', { normalizeUnicode: false })).toBe(true);
});
});

describe('timingSafeBufferEqual', () => {
it.each([
[[], [], true],
[[1, 2, 255], [1, 2, 255], true],
[[1, 2, 3], [0, 2, 3], false],
[[1, 2, 3], [1, 0, 3], false],
[[1, 2, 3], [1, 2, 0], false],
[[1, 1], [0, 0], false],
[[1], [1, 2], false],
[[1, 2], [1], false],
[[], [0], false],
[[0], [], false],
[[0, 0], [0], false],
[[0], [0, 0], false],
[[0, 0], [0, 0], true],
] as const)('compares %j and %j as %s', (a, b, expected) => {
expect(timingSafeBufferEqual(Uint8Array.from(a), Uint8Array.from(b))).toBe(expected);
});

it('respects subarray boundaries without modifying inputs', () => {
const a = new Uint8Array([99, 1, 2, 3, 99]);
const b = new Uint8Array([88, 1, 2, 3, 88]);
expect(timingSafeBufferEqual(a.subarray(1, 4), b.subarray(1, 4))).toBe(true);
expect(timingSafeBufferEqual(a.subarray(1, 4), a.subarray(0, 3))).toBe(false);
expect(timingSafeBufferEqual(a, a)).toBe(true);
expect(a).toEqual(new Uint8Array([99, 1, 2, 3, 99]));
expect(b).toEqual(new Uint8Array([88, 1, 2, 3, 88]));
});

it('detects a difference at every byte position in a larger buffer', () => {
const a = Uint8Array.from({ length: 256 }, (_, i) => i);
for (let i = 0; i < a.length; i++) {
const b = a.slice();
b[i] ^= 255;
expect(timingSafeBufferEqual(a, b)).toBe(false);
}
expect(timingSafeBufferEqual(a, a.slice())).toBe(true);
});

it.each([[9, 2, 3], [9]])('reads the full first buffer even when comparing against %j', (...bytes) => {
const visited: number[] = [];
const a = new Proxy(new Uint8Array([1, 2, 3]), {
get(target, property) {
if (typeof property === 'string' && /^\d+$/.test(property)) {
visited.push(Number(property));
}
return Reflect.get(target, property, target);
},
});
expect(timingSafeBufferEqual(a, new Uint8Array(bytes))).toBe(false);
expect(visited).toEqual([0, 1, 2]);
});
});

describe('timingSafeSelect', () => {
it.each([
['yes', 'no'],
['a', 'longer'],
['longer', 'a'],
['', ''],
['', 'value'],
['value', ''],
['same', 'same'],
['café', 'cafe\u0301'],
['你好🔒', '🔓'],
['a\0\0', '\0b'],
['\uD800', '\uDC00'],
])('selects exactly from %j and %j for either condition', (ifTrue, ifFalse) => {
expect(timingSafeSelect(true, ifTrue, ifFalse)).toBe(ifTrue);
expect(timingSafeSelect(false, ifTrue, ifFalse)).toBe(ifFalse);
});

it('handles long candidates without argument-spread limits', () => {
const long = '🔒'.repeat(70000);
expect(timingSafeSelect(true, long, 'short')).toBe(long);
expect(timingSafeSelect(false, long, 'short')).toBe('short');
});
});
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.tsx', 'tests/elo-rating.test.ts'],
include: ['tests/**/*.test.tsx', 'tests/constant-time.test.ts', 'tests/elo-rating.test.ts'],
},
});
Loading