fix: constant_time_eq no longer leaks secret length via early return

Remove the early return on length mismatch that leaked length
information via timing. Now iterates over max(a.len(), b.len()),
padding the shorter input with zeros, and checks both byte-level
differences and length equality at the end.

Closes #57

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fettpl 2026-02-15 00:01:23 +01:00
parent 365692853c
commit 6776373e8e
3 changed files with 21 additions and 8 deletions

View file

@ -174,15 +174,27 @@ fn generate_token() -> String {
format!("zc_{}", uuid::Uuid::new_v4().as_simple())
}
/// Constant-time string comparison to prevent timing attacks on pairing code.
/// Constant-time string comparison to prevent timing attacks.
///
/// Does not short-circuit on length mismatch — always iterates over the
/// longer input to avoid leaking length information via timing.
pub fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
let a = a.as_bytes();
let b = b.as_bytes();
// Track length mismatch as a usize (non-zero = different lengths)
let len_diff = a.len() ^ b.len();
// XOR each byte, padding the shorter input with zeros.
// Iterates over max(a.len(), b.len()) to avoid timing differences.
let max_len = a.len().max(b.len());
let mut byte_diff = 0u8;
for i in 0..max_len {
let x = if i < a.len() { a[i] } else { 0 };
let y = if i < b.len() { b[i] } else { 0 };
byte_diff |= x ^ y;
}
a.bytes()
.zip(b.bytes())
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
== 0
len_diff == 0 && byte_diff == 0
}
/// Check if a host string represents a non-localhost bind address.

View file

@ -241,7 +241,7 @@ fn hex_encode(data: &[u8]) -> String {
/// Hex-decode a hex string to bytes.
fn hex_decode(hex: &str) -> Result<Vec<u8>> {
if hex.len() % 2 != 0 {
if !hex.len().is_multiple_of(2) {
anyhow::bail!("Hex string has odd length");
}
(0..hex.len())

View file

@ -366,6 +366,7 @@ impl BrowserTool {
}
#[async_trait]
#[allow(clippy::too_many_lines)]
impl Tool for BrowserTool {
fn name(&self) -> &str {
"browser"