Secure String Comparison

Coincidence

I was tasked with creating a mock login system my club, Google Developers Group at USF, when I came across a very interesting aspect many people overlook while making login systems. On this website, the admin credentials were hardcoded on the frontend. Usernames and passwords were sitting right in the JavaScript, which meant that anyone who opened their browser’s developer tools could read them.

The obvious fix was to move the check to the backend, so that the real credentials never leave the server since NextJS offers SSR. That is where I ran into something that I never gave any though to: how to compare the password the user sent with the one I have stored.

The obvious way

My instinct was to do this:

function safeEqual(a: string, b: string): boolean {
  return a === b
}

This looks fine, and for most things it is. The problem is that string comparison walks through the two values byte by byte and returns immediately when it hits the first byte that does not match. If the very first byte is wrong, it stops right there and returns false. This is where the vulnerability is.

Timing attacks

It is very easy to measure how long the server takes to reject each guess.For example, “aaaa…” that gets rejected in 1µs matched 0 bytes; “maaa…” rejected in 2µs matched 1 byte.

By maximizing the response time one byte at a time, the attacker can rebuild the secret from start to finish without ever guessing the whole thing at once. This is called a timing attack, and it’s why using normal string comparisons can leak passwords.

Comparing in constant time

The fix is to compare the values in constant time. Node gives you this through timingSafeEqual:

import { timingSafeEqual } from 'crypto'

timingSafeEqual(Buffer.from(a), Buffer.from(b))

For two strings of the same length, this function always takes the same amount of time to compare them. This prevents the attacker from guessing the password from the time difference.

The length problem

However, timingSafeEqual is not quite perfect. It throws if the two buffers are not the same length, and a length difference is itself a leak. If a short guess fails instantly with an error while a longer guess runs the full comparison, the attacker learns something about how long the real secret is.

So we need both sides to always be the same length before they reach the comparison, and we need the real length of the secret to never show up there at all.

Hashing first

The way to get that is to run both values through HMAC before comparing them:

// hmac is not a real function

function safeEqual(a: string, b: string): boolean {
  return timingSafeEqual(hmac(a), hmac(b))
}

An HMAC output is a fixed size no matter what you feed it. With something like HMAC-SHA256, every output is exactly 32 bytes, whether the input was one character or ten thousand. That gives us two things at once. The two buffers always have the same length, so timingSafeEqual never throws, and the actual length and content of the secret never reach the comparison.

P/S: JWT is an alternative to HMAC, but for this particular app, where only one pair of credentials is hardcoded, there is little purpose in using JWT.