Skip to content
Closed
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: 13 additions & 0 deletions bench/crypto/timing-safe-equal.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import crypto from "node:crypto";
import { bench, run } from "../runner.mjs";

for (const size of [16, 32, 64, 256, 1024, 4096, 65536]) {
const a = crypto.randomBytes(size);
const b = Buffer.from(a);

bench(`timingSafeEqual - ${size} bytes`, () => {
return crypto.timingSafeEqual(a, b);
});
}

await run();
3 changes: 2 additions & 1 deletion scripts/verify-baseline-static/allowlist-aarch64.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# ----------------------------------------------------------------------------
# Bun's Highway SVE/SVE2 targets. Gate: hwy::SupportedTargets via getauxval(AT_HWCAP).
# (168 symbols)
# (169 symbols)
# ----------------------------------------------------------------------------
_ZN3bun10N_SVE2_12810MemMemImplEPKhmS2_m [SVE]
_ZN3bun10N_SVE2_12811MemRMemImplEPKhmS2_m [SVE]
Expand Down Expand Up @@ -175,6 +175,7 @@ _ZN3bun10N_SVE2_12811BSwap32ImplEPhm [
_ZN3bun10N_SVE2_12811BSwap64ImplEPhm [SVE]
_ZN3bun10N_SVE2_128L12MemMemSearchILb0EtEEmPKT0_mS4_mmmPm [SVE]
_ZN3bun10N_SVE2_128L12MemMemSearchILb1EtEEmPKT0_mS4_mmmPm [SVE]
_ZN3bun10N_SVE2_12821ConstantTimeEqualImplEPKhS2_m [SVE]


# ----------------------------------------------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion scripts/verify-baseline-static/allowlist-x64-windows.txt
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ ctiMasmProbeTrampolineAVX [AVX]

# ----------------------------------------------------------------------------
# Highway. MSVC-mangled bun::N_AVX* names.
# (280 symbols)
# (286 symbols)
# ----------------------------------------------------------------------------
bun::N_AVX10_2::ContainsNewlineOrNonASCIIOrQuoteImpl [AVX, AVX512BW, AVX512F]
bun::N_AVX10_2::CopyAsciiPrefixImpl [AVX, AVX512BW, AVX512F, AVX512VL]
Expand Down Expand Up @@ -719,6 +719,12 @@ bun::N_AVX3_SPR::BSwap64Impl [
bun::N_AVX3_ZEN4::BSwap16Impl [AVX, AVX2, AVX512BW, AVX512F]
bun::N_AVX3_ZEN4::BSwap32Impl [AVX, AVX2, AVX512BW, AVX512F, AVX512VL]
bun::N_AVX3_ZEN4::BSwap64Impl [AVX, AVX2, AVX512BW, AVX512F, AVX512VL]
bun::N_AVX10_2::ConstantTimeEqualImpl [AVX, AVX512BW, AVX512F, BMI2]
bun::N_AVX2::ConstantTimeEqualImpl [AVX, AVX2]
bun::N_AVX3::ConstantTimeEqualImpl [AVX, AVX512BW, AVX512F, BMI2]
bun::N_AVX3_DL::ConstantTimeEqualImpl [AVX, AVX512BW, AVX512F, BMI2]
bun::N_AVX3_SPR::ConstantTimeEqualImpl [AVX, AVX512BW, AVX512F, BMI2]
bun::N_AVX3_ZEN4::ConstantTimeEqualImpl [AVX, AVX512BW, AVX512F, BMI2]


# ----------------------------------------------------------------------------
Expand Down
8 changes: 7 additions & 1 deletion scripts/verify-baseline-static/allowlist-x64.txt
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ ctiMasmProbeTrampolineAVX [AVX]

# ----------------------------------------------------------------------------
# Bun's Highway SIMD. Gate: BUN_HWY_DISPATCH (highway_dispatch.h) via hwy::SupportedTargets.
# (280 symbols)
# (286 symbols)
# ----------------------------------------------------------------------------
_ZN3bun10N_AVX3_SPR10MemMemImplEPKhmS2_m [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2]
_ZN3bun10N_AVX3_SPR11MemRMemImplEPKhmS2_m [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI2]
Expand Down Expand Up @@ -741,6 +741,12 @@ _ZN3bun6N_AVX3L12MemMemSearchILb1EtEEmPKT0_mS4_mmmPm [
_ZN3bun9N_AVX3_DL11BSwap16ImplEPhm [AVX, AVX2, AVX512BW, AVX512F]
_ZN3bun9N_AVX3_DL11BSwap32ImplEPhm [AVX, AVX2, AVX512BW, AVX512F, AVX512VL]
_ZN3bun9N_AVX3_DL11BSwap64ImplEPhm [AVX, AVX2, AVX512BW, AVX512F, AVX512VL]
_ZN3bun10N_AVX3_SPR21ConstantTimeEqualImplEPKhS2_m [AVX, AVX512BW, AVX512F, BMI2]
_ZN3bun11N_AVX3_ZEN421ConstantTimeEqualImplEPKhS2_m [AVX, AVX512BW, AVX512F, BMI2]
_ZN3bun6N_AVX221ConstantTimeEqualImplEPKhS2_m [AVX, AVX2]
_ZN3bun6N_AVX321ConstantTimeEqualImplEPKhS2_m [AVX, AVX512BW, AVX512F, BMI2]
_ZN3bun9N_AVX10_221ConstantTimeEqualImplEPKhS2_m [AVX, AVX512BW, AVX512F, BMI2]
_ZN3bun9N_AVX3_DL21ConstantTimeEqualImplEPKhS2_m [AVX, AVX512BW, AVX512F, BMI2]


# ----------------------------------------------------------------------------
Expand Down
16 changes: 16 additions & 0 deletions src/highway/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ unsafe extern "C" {
skip_mask: bool,
);

fn highway_constant_time_eq(a: *const u8, b: *const u8, len: usize) -> bool;

fn highway_copy_u16_to_u8(input: *const u16, count: usize, output: *mut u8);

fn highway_copy_ascii_prefix(src: *const u8, len: usize, dst: *mut u8) -> usize;
Expand Down Expand Up @@ -728,6 +730,20 @@ pub fn fill_with_skip_mask_inplace(mask: [u8; 4], buf: &mut [u8], skip_mask: boo
}
}

/// Below one AVX-512 vector, the dispatch call of [`constant_time_eq`] costs more than a byte loop.
pub const CONSTANT_TIME_EQ_MIN_LEN: usize = 64;

/// Constant-time equality: the time depends on the length only. Different lengths compare unequal.
#[inline(always)]
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
// SAFETY: both slices are readable for `a.len()` bytes (the lengths are
// equal); the kernel only reads, so `a` and `b` may overlap.
unsafe { highway_constant_time_eq(a.as_ptr(), b.as_ptr(), a.len()) }
}

/// Useful for single-line JavaScript comments.
/// Scans for:
/// - `\n`, `\r`
Expand Down
4 changes: 3 additions & 1 deletion src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const xxHash3ForTesting: (view: ArrayBufferView, seed?: number | bigint)
// behind `bun_core::strings`, driven directly so tests can sweep lengths and
// alignments. Returns the kernel's raw result: an index (`haystack.length` =
// not found), a count, or for memmem/memrmem the offset with -1 = not found.
// constantTimeEq (behind `crypto.timingSafeEqual` from 64 bytes): `haystack` and `arg` of one length, 1 = equal.
export const highwayStringsForTesting: (
op:
| "indexOfChar"
Expand All @@ -38,7 +39,8 @@ export const highwayStringsForTesting: (
| "memmem"
| "memrmem"
| "memmem16"
| "memrmem16",
| "memrmem16"
| "constantTimeEq",
haystack: Uint8Array,
arg: number | Uint8Array,
) => number = $newCppFunction("highway_strings_testing.cpp", "Bun__highwayStringsForTesting", 3);
Expand Down
49 changes: 49 additions & 0 deletions src/jsc/bindings/highway_strings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2277,6 +2277,49 @@ void FillWithSkipMaskImpl(const uint8_t* HWY_RESTRICT mask, size_t mask_len, uin
}
}

// The compiler cannot reason about the returned value (the idiom of BoringSSL's value_barrier_w).
static HWY_INLINE uint64_t ValueBarrier(uint64_t v)
{
__asm__("" : "+r"(v) : /* no inputs */);
return v;
}

// Behind the barrier the compiler cannot tell that only "any difference" matters, so it cannot exit early.
template<class V>
static HWY_INLINE bool NoLaneDiffers(D8 d, V diff)
{
return ValueBarrier(hn::BitsFromMask(d, hn::Ne(diff, hn::Zero(d)))) == 0;
}

// Constant-time: the loads depend on `len` only, and the fold is tested once at the end. `a` may alias `b`.
bool ConstantTimeEqualImpl(const uint8_t* a, const uint8_t* b, size_t len)
{
D8 d;
const size_t N = hn::Lanes(d);
if (HWY_UNLIKELY(len < N)) {
return NoLaneDiffers(d, hn::Xor(hn::LoadN(d, a, len), hn::LoadN(d, b, len)));
}

// Four accumulators keep the 128-bit targets level with a compiler-vectorized byte loop.
auto diff0 = hn::Zero(d);
auto diff1 = hn::Zero(d);
auto diff2 = hn::Zero(d);
auto diff3 = hn::Zero(d);
size_t i = 0;
for (; i + 4 * N <= len; i += 4 * N) {
diff0 = hn::Or(diff0, hn::Xor(hn::LoadU(d, a + i), hn::LoadU(d, b + i)));
diff1 = hn::Or(diff1, hn::Xor(hn::LoadU(d, a + i + N), hn::LoadU(d, b + i + N)));
diff2 = hn::Or(diff2, hn::Xor(hn::LoadU(d, a + i + 2 * N), hn::LoadU(d, b + i + 2 * N)));
diff3 = hn::Or(diff3, hn::Xor(hn::LoadU(d, a + i + 3 * N), hn::LoadU(d, b + i + 3 * N)));
}
for (; i + N <= len; i += N) {
diff0 = hn::Or(diff0, hn::Xor(hn::LoadU(d, a + i), hn::LoadU(d, b + i)));
}
// The last vector overlaps bytes that are already folded in.
diff1 = hn::Or(diff1, hn::Xor(hn::LoadU(d, a + len - N), hn::LoadU(d, b + len - N)));
return NoLaneDiffers(d, hn::Or(hn::Or(diff0, diff1), hn::Or(diff2, diff3)));
}

} // namespace HWY_NAMESPACE
} // namespace bun
HWY_AFTER_NAMESPACE();
Expand All @@ -2292,6 +2335,7 @@ namespace bun {
HWY_EXPORT(BSwap16Impl);
HWY_EXPORT(BSwap32Impl);
HWY_EXPORT(BSwap64Impl);
HWY_EXPORT(ConstantTimeEqualImpl);
HWY_EXPORT(ContainsNewlineOrNonASCIIOrQuoteImpl);
HWY_EXPORT(CopyAsciiPrefixImpl);
HWY_EXPORT(CopyU16ToU8Impl);
Expand Down Expand Up @@ -2523,6 +2567,11 @@ void highway_fill_with_skip_mask(
BUN_HWY_DISPATCH(FillWithSkipMaskImpl)(mask, mask_len, output, input, length, skip_mask);
}

bool highway_constant_time_eq(const uint8_t* a, const uint8_t* b, size_t len)
{
return BUN_HWY_DISPATCH(ConstantTimeEqualImpl)(a, b, len);
}

void highway_bswap16(uint8_t* data, size_t len)
{
BUN_HWY_DISPATCH(BSwap16Impl)(data, len);
Expand Down
8 changes: 8 additions & 0 deletions src/jsc/bindings/highway_strings_testing.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ extern "C" void* highway_memmem(const uint8_t* haystack, size_t haystack_len, co
extern "C" size_t highway_memrmem(const uint8_t* haystack, size_t haystack_len, const uint8_t* needle, size_t needle_len);
extern "C" size_t highway_memmem16(const uint16_t* haystack, size_t haystack_len, const uint16_t* needle, size_t needle_len);
extern "C" size_t highway_memrmem16(const uint16_t* haystack, size_t haystack_len, const uint16_t* needle, size_t needle_len);
extern "C" bool highway_constant_time_eq(const uint8_t* a, const uint8_t* b, size_t len);

namespace Bun {

Expand All @@ -32,6 +33,7 @@ namespace Bun {
// others. Returns exactly what the kernel returns: an index (with
// `haystack.length` meaning "not found" for the index_of family), a count, or
// for memmem/memrmem the match offset with -1 for "not found".
// constantTimeEq compares `haystack` with an `arg` view of the same length: 1 = equal.
BUN_DEFINE_HOST_FUNCTION(Bun__highwayStringsForTesting, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto& vm = JSC::getVM(globalObject);
Expand Down Expand Up @@ -88,6 +90,12 @@ BUN_DEFINE_HOST_FUNCTION(Bun__highwayStringsForTesting, (JSC::JSGlobalObject * g
} else if (op == "memrmem"_s) {
size_t r = highway_memrmem(haystack, len, needle, needle_len);
RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(r == static_cast<size_t>(-1) ? -1.0 : static_cast<double>(r))));
} else if (op == "constantTimeEq"_s) {
if (arg.isNumber() || needle_len != len) {
throwRangeError(globalObject, scope, "constantTimeEq needs two views of the same byte length"_s);
return {};
}
result = highway_constant_time_eq(haystack, needle, len) ? 1 : 0;
} else if (op == "memmem16"_s || op == "memrmem16"_s) {
// Byte views reinterpreted as UTF-16 code units (any alignment, odd byte
// dropped, as JSBuffer.cpp does); result is in code units.
Expand Down
7 changes: 6 additions & 1 deletion src/runtime/node/node_crypto_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1208,7 +1208,12 @@ mod _impl {
.throw());
}

Ok(JSValue::from(boringssl::c::constant_time_eq(l, r)))
let equal = if l.len() < bun_highway::CONSTANT_TIME_EQ_MIN_LEN {
boringssl::c::constant_time_eq(l, r)
} else {
bun_highway::constant_time_eq(l, r)
};
Ok(JSValue::from(equal))
}

#[bun_jsc::host_fn]
Expand Down
66 changes: 66 additions & 0 deletions test/js/node/crypto/crypto.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CryptoHasher, MD4, MD5, SHA1, SHA224, SHA256, SHA384, SHA512, SHA512_256, gc } from "bun";
import { highwayStringsForTesting } from "bun:internal-for-testing";
import { describe, expect, it } from "bun:test";
import crypto from "crypto";
import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness";
Expand Down Expand Up @@ -228,6 +229,71 @@ describe("crypto.getCurves", () => {
});
});

describe("timingSafeEqual", () => {
// Below 64 bytes the native compare is BoringSSL's byte loop. From 64 bytes
// it is a SIMD kernel: blocks of four vectors, then single vectors, then one
// last vector that overlaps the previous one, or one partial load when the
// input is shorter than a vector. A vector is 16, 32 or 64 bytes, depending
// on the CPU.
type Compare = (a: Uint8Array, b: Uint8Array) => boolean;

function findMisses(equal: Compare, lengths: number[], positions: (length: number) => number[]) {
const misses: string[] = [];
const max = Math.max(...lengths);
const left = crypto.randomBytes(max + 1);
const right = Buffer.alloc(max + 2);
for (const length of lengths) {
// Different odd offsets, so neither side is aligned.
const a = left.subarray(1, 1 + length);
const b = right.subarray(2, 2 + length);
a.copy(b);
if (equal(a, b) !== true) misses.push(`length ${length}: equal inputs`);
for (const i of positions(length)) {
const bit = 1 << (i & 7);
b[i] ^= bit;
if (equal(a, b) !== false) misses.push(`length ${length}: byte ${i} differs by ${bit}`);
b[i] ^= bit;
}
}
return misses;
}

const range = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, i) => from + i);
const everyPosition = (length: number) => range(0, length - 1);

describe.each([
["node:crypto", crypto.timingSafeEqual],
["globalThis.crypto", globalThis.crypto.timingSafeEqual.bind(globalThis.crypto)],
])("%s", (_, timingSafeEqual) => {
it("sees one flipped bit at every position, for every length up to 137", () => {
expect(findMisses(timingSafeEqual, range(0, 137), everyPosition)).toEqual([]);
});

it("sees one flipped bit in large inputs", () => {
// 709 = 2 blocks + 3 vectors + 5 bytes at 64 bytes per vector.
const lengths = [709, 1024, 4096 + 5, 16384 + 31];
// A stride of 61 is coprime to every vector width, so the flipped byte moves through the lanes.
// The last 64 bytes cover the overlapping vector.
const positions = (length: number) => [
...range(0, Math.floor((length - 1) / 61)).map(i => i * 61),
...range(length - 64, length - 1),
];
expect(findMisses(timingSafeEqual, lengths, positions)).toEqual([]);
});
});

it("the SIMD kernel sees one flipped bit at every position, for every length up to 330", () => {
// timingSafeEqual calls the kernel from 64 bytes, so only the testing shim reaches the short lengths.
const kernel: Compare = (a, b) => highwayStringsForTesting("constantTimeEq", a, b) === 1;
expect(findMisses(kernel, range(0, 330), everyPosition)).toEqual([]);

const same = crypto.randomBytes(1029);
expect(kernel(same, same)).toBe(true);
expect(kernel(new Uint8Array(0), new Uint8Array(0))).toBe(true);
expect(() => kernel(same, same.subarray(1))).toThrow(RangeError);
});
});

describe("crypto", () => {
for (let Hash of HashClasses) {
for (let [input, label] of [
Expand Down
Loading