From df2953e3965c67b71a59313bd1f54799437e10b9 Mon Sep 17 00:00:00 2001 From: Akshat Date: Wed, 21 Oct 2026 15:03:17 +0530 Subject: [PATCH] feat(security): add constant-time comparison helpers --- scratch/run-tests.mjs | 7 +- src/lib/security/constant-time.ts | 74 +++++++++++++++++ tests/constant-time.test.ts | 131 ++++++++++++++++++++++++++++++ vitest.config.ts | 4 +- 4 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 src/lib/security/constant-time.ts create mode 100644 tests/constant-time.test.ts diff --git a/scratch/run-tests.mjs b/scratch/run-tests.mjs index fbaab3b..eae5998 100644 --- a/scratch/run-tests.mjs +++ b/scratch/run-tests.mjs @@ -5,6 +5,8 @@ import path from 'node:path'; 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(['constant-time.test.ts']); // Authoritative offline unit, domain, security, and integration test suites const liveDbTests = new Set([ @@ -44,7 +46,10 @@ for (const file of files) { console.log(`------------------------------------------------------------`); const start = Date.now(); - const res = spawnSync(nodeBin, [jitiCli, filePath], { + const args = vitestTests.has(file) + ? [vitestCli, 'run', filePath] + : [jitiCli, filePath]; + const res = spawnSync(nodeBin, args, { cwd: process.cwd(), stdio: 'inherit', }); diff --git a/src/lib/security/constant-time.ts b/src/lib/security/constant-time.ts new file mode 100644 index 0000000..5290cb7 --- /dev/null +++ b/src/lib/security/constant-time.ts @@ -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); +} diff --git a/tests/constant-time.test.ts b/tests/constant-time.test.ts new file mode 100644 index 0000000..c4fa8dc --- /dev/null +++ b/tests/constant-time.test.ts @@ -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'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 26a5452..4c00ff2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,6 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./tests/setup.ts'], - include: ['tests/**/*.test.tsx'], + include: ['tests/**/*.test.tsx', 'tests/constant-time.test.ts'], }, -}); \ No newline at end of file +});