Fixing a timing attack

347 Views Asked by At

I use this code to login a user. The password is encrypted with bcrypt and the SALT_ROUNDS is the same for every user

const user = await User.findOne({email: args.email});
if (!user || !await user.comparePassword(args.password)) throw new Error("User or Password is not correct");

(comparePassword is a mongoose function)

UserSchema.methods.comparePassword = async function (candidatePassword) {
    return await bcrypt.compare(candidatePassword, this.password);
};

As you can see, due to short circuiting the if-check can take a different time to execute - which is measurable for the client.

This means, that a client can find out if a certain email uses our services - which is a minor data leak.

I would need a script like this:

const err = new Error("User or Password is not correct.")
if (!user) {
  await "wait as long as a password comparison would usually take"
  throw err
} else if (!await user.comparePassword(args.password)) {
  throw err
}

but don't know how to implement it.

One idea would be to create a Dummy User to create the comparePassword on if !user, but I am not sure about the upsides/drawbacks of that or if there is a better solution.

Edit: how about I wrap all of this up in a setTimeout function? It will take 1Sec (or 500ms or something) no matter what.

1

There are 1 best solutions below

0
On

I came up with this solution:

const user = await User.findOne({email: args.email});

const start = performance.now()
const errorToThrow = (!user || !await user.comparePassword(args.password))
const timeElapsed = performance.now() - start

//console.log(`time elapsed: ${timeElapsed}`)
//wait constant time to protect against timing attacks
// OPTIMIZE still able to go for timing attack if `comparePassword` takes >1000ms
await new Promise((resolve) => {
    setTimeout(resolve, 1000 - timeElapsed);
});
//console.log(`Constant wait time: ${performance.now() - start}`)

if (errorToThrow) throw new Error("User or Password is not correct.")

//generate token
return jwt.sign({
        _id: user._id,
        username: user.name,
        role: user.role
    },
    process.env.JWT_SECRET, {
        expiresIn: "24h"
    });

and measuring performance shows this works

#successful login
time elapsed: 111.46860000118613
Constant wait time: 1008.9731000009924
time elapsed: 77.95830000005662
Constant wait time: 1011.4061999991536
time elapsed: 77.98919999971986
Constant wait time: 1001.5624000001699
time elapsed: 73.09439999982715
Constant wait time: 1000.9662999995053

#user doesnt exist
time elapsed: 0.002399999648332596
Constant wait time: 1015.8186999987811
time elapsed: 0.002199999988079071
Constant wait time: 1006.2013000007719
time elapsed: 0.0023000016808509827
Constant wait time: 1006.3976000007242
time elapsed: 0.0037999991327524185
Constant wait time: 1003.9857000000775

#password incorrect
time elapsed: 74.4993999991566
Constant wait time: 1009.0491999983788
time elapsed: 73.85539999976754
Constant wait time: 1012.3958000000566
time elapsed: 75.48650000058115
Constant wait time: 1014.1987999994308
time elapsed: 71.53899999894202
Constant wait time: 1007.4519999995828
time elapsed: 74.53729999996722
Constant wait time: 1001.8896000012755

So unless it takes the server 10x the usual time to respond this works.