Check a dummy hash when a login names no account

Without a password to check, a login for an unknown account skipped bcrypt
and answered far sooner than one with a wrong password, which still told
which emails have an account.
This commit is contained in:
Alexis Loskoutoff committed 2026-11-05 18:29:52 +01:00
1 parent c08f2e65a8
commit e93b771ff5
2 files changed
+32 -12

No files matched your search

+13 -11
View File
@@ -34,18 +34,20 @@ export default {
.andWhere("is_disabled", 0)
.first();
if (!user) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
// An unknown, disabled or passwordless account goes through the same queries and the same password check
// as a real one, and fails with the same error: answering any sooner or any differently would tell anyone
// trying addresses which ones have an account. There is no user 0, so its lookup finds nothing. An empty
// secret, which creating a user through the API can store, is no hash: bcrypt would reject it at once.
const auth = await authModel
.query()
.where("user_id", "=", user?.id ?? 0)
.where("type", "=", "password")
.first();
const auth = await authModel.query().where("user_id", "=", user.id).where("type", "=", "password").first();
if (!auth) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
const valid = await auth.verifyPassword(data.secret);
if (!valid) {
const valid = auth?.secret
? await auth.verifyPassword(data.secret)
: await authModel.verifyDummyPassword(data.secret);
if (!user || !valid) {
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH, ERROR_MESSAGE_INVALID_AUTH_I18N);
}
+19 -1
View File
@@ -1,6 +1,7 @@
// Objection Docs:
// http://vincit.github.io/objection.js/
import crypto from "node:crypto";
import bcrypt from "bcrypt";
import { Model } from "objection";
import db from "../db.js";
@@ -11,10 +12,15 @@ import User from "./user.js";
Model.knex(db());
const boolFields = ["is_deleted"];
const saltRounds = 13;
// A hash of a random value, for a login that names an account with no password to check it against. Hashed
// with the same cost as real passwords, and made at startup so the first such login takes no longer either.
const dummyHash = bcrypt.hash(crypto.randomBytes(32).toString("hex"), saltRounds);
function encryptPassword() {
if (this.type === "password" && this.secret) {
return bcrypt.hash(this.secret, 13).then((hash) => {
return bcrypt.hash(this.secret, saltRounds).then((hash) => {
this.secret = hash;
});
}
@@ -60,6 +66,18 @@ class Auth extends Model {
return bcrypt.compare(password, this.secret);
}
/**
* Take as long as verifyPassword does, then fail. bcrypt is nearly all of the time a login takes, so a login
* that skipped it for an unknown account would answer far sooner, and say which accounts exist.
*
* @param {String} password
* @returns {Promise<false>}
*/
static async verifyDummyPassword(password) {
await bcrypt.compare(password, await dummyHash);
return false;
}
static get name() {
return "Auth";
}