fix: run dummy bcrypt.compare on unknown user to prevent timing side-channel

This commit is contained in:
toanhoang
2026-08-04 06:39:08 -04:00
parent 44dd33dd3b
commit 2a0acd11a9
2 changed files with 36 additions and 0 deletions
+20
View File
@@ -20,6 +20,21 @@ const BCRYPT_SALT_ROUNDS = 10;
// grows into employee self-service.
const MIN_PASSWORD_LENGTH = 8;
// Precomputed bcrypt hash of a fixed dummy password, hashed with the same
// cost factor (BCRYPT_SALT_ROUNDS) used for real user passwords. Used as a
// timing-safety decoy in verifyUserCredentials() below: when the looked-up
// email doesn't exist, we still run a bcrypt.compare() against this hash
// (whose result is always discarded) so that the "unknown email" code path
// takes roughly the same time as the "wrong password" path — otherwise the
// early return would let an attacker distinguish "email doesn't exist" from
// "email exists, password wrong" purely by response latency (bcrypt.compare
// dominates request time). Computed once at module load via hashSync (not
// per-request) since hashing itself is deliberately expensive.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
"dummy-password-for-timing-safety",
BCRYPT_SALT_ROUNDS
);
export interface UserRecord {
id: string;
email: string;
@@ -138,6 +153,11 @@ export async function verifyUserCredentials(
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
// Run a dummy compare against a fixed decoy hash and discard the result
// — see DUMMY_PASSWORD_HASH comment above. This keeps timing consistent
// with the "email exists, password wrong" branch below instead of
// returning early.
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
return null;
}
+16
View File
@@ -115,4 +115,20 @@ describe("verifyUserCredentials", () => {
expect(await verifyUserCredentials(email, undefined)).toBeNull();
expect(await verifyUserCredentials(123, password)).toBeNull();
});
// Timing side-channel fix: an unknown email must not short-circuit to a
// different outcome shape than a known email with the wrong password —
// both must be indistinguishable (null) so a caller/attacker can't infer
// which case occurred from the result alone. (The actual timing-safety
// property — running a dummy bcrypt.compare() on the unknown-email path —
// isn't asserted here since timing isn't reliably testable in a unit
// test; this only pins down the observable behavior staying identical.)
it("returns the same result (null) for an unknown email as for a known email with the wrong password", async () => {
const unknownEmailResult = await verifyUserCredentials("[email protected]", password);
const wrongPasswordResult = await verifyUserCredentials(email, "wrong-password");
expect(unknownEmailResult).toBeNull();
expect(wrongPasswordResult).toBeNull();
expect(unknownEmailResult).toBe(wrongPasswordResult);
});
});