2

I'm using bcryptjs to hash a user's refresh_token before storing it in my database.

It seems that the following always evaluates to true when comparing a hashed string with a JWT, I've also gotten the same behavior on https://bcrypt-generator.com/

for example the hash $2a$10$z4rwnyg.cVtP2SHt3lYj7.aGeAzonmmzbxqCzi2UW3SQj6famGaqW is a match with the following two JWTs

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk1OTg2MDIsImV4cCI6MTY0MjE5MDYwMn0.aJlzFHhBMGO4J7vlOudqOrOFnL1P-yEGrREgdaCXlxU

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk2MDY4ODgsImV4cCI6MTY0MjE5ODg4OH0.vo4HKLXuQbT0Yb0j21M4xl-rakxyE5wINjuGdkPuSJY

You can verify these on the site as well that they both result in a 'match'

  1. Go to https://bcrypt-generator.com/ and open your browser console.

  2. Enter these lines into the console:

    > var jwt1 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk1OTg2MDIsImV4cCI6MTY0MjE5MDYwMn0.aJlzFHhBMGO4J7vlOudqOrOFnL1P-yEGrREgdaCXlxU"
    < undefined
    
    > var jwt2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk2MDY4ODgsImV4cCI6MTY0MjE5ODg4OH0.vo4HKLXuQbT0Yb0j21M4xl-rakxyE5wINjuGdkPuSJY"
    < undefined
    
    > var h = "$2a$10$z4rwnyg.cVtP2SHt3lYj7.aGeAzonmmzbxqCzi2UW3SQj6famGaqW"
    < undefined
    
  3. Then enter these lines into the console, observe how they return true:

    > bcrypt.compareSync(jwt1, h)
    < true
    > bcrypt.compareSync(jwt2, h)
    < true
    

This is my own JS code that also reproduces the hash match:

// Login Logic

const refresh_token: string = jwt.sign({ userId }, authSecrets.refresh_secret, { expiresIn: '30d' });

const hash_refresh = bcrypt.hashSync(refresh_token);

await UserModel.update({
    id: user.id,
    refresh_token: hash_refresh,
});


// Refresh logic
// 'value' is the payload after using joi to validate it 

const claims: any = jwt.verify(value.refresh_token, authSecrets.refresh_secret);

user = await UserModel.get(claims.userId);

if (!bcrypt.compareSync(value.refresh_token, user.refresh_token)) {
    // This never happens with any JWT!
    return response(401, 'Refresh Token is incorrect');
}

Why is this happening? the strings are clearly different (although not by a lot).

Dai
  • 141,631
  • 28
  • 261
  • 374
JamesRichardson
  • 131
  • 2
  • 13
  • "that the following always evaluates to true" - but you haven't actually posted any JS expressions that "evaluates to true" - instead you've posted multi-statement JS examples that don't _evaluate_ to anything because a statement isn't an expression. And we need example values for `userId` and other parameters. – Dai Dec 15 '21 at 22:39
  • `bcrypt.compareSync(value.refresh_token, user.refresh_token)` this is the expression that evaluates to true, you'll find it within the 'if' statement for bcrypt-generator.com you can use the hash I posted above and use either of the JWT strings above and they will both evaluate to true. userId is an UUID and should not effect the problem here – JamesRichardson Dec 15 '21 at 22:44
  • There are hundreds of `if` statements on bcrypt-generator.com from all the scripts loaded onto it. Please be more specific. Just give me some JS I can literally copy-and-paste into my browser console. – Dai Dec 15 '21 at 22:46
  • Okay, I am now able to reproduce the issue you're describing after fiddling around on bcrypt-generator.com. I agree, this is weird. – Dai Dec 15 '21 at 22:52
  • 1
    BTW, [the `bcryptjs` library](https://www.npmjs.com/package/bcryptjs) hasn't been updated in **5 years**, have you considered [the `bcrypt` package](https://www.npmjs.com/package/bcrypt) instead? That's still actively developed. – Dai Dec 15 '21 at 22:58
  • Sorry I may have been unclear in my original question, the code snippet was from my own source code, not bcrypt-generator.com, but I was able to reproduce it through the site as well, I thought it might be easier to reproduce it on the site than try to replicate my source code locally (installing packages, etc) – JamesRichardson Dec 15 '21 at 22:59
  • Ah, here we go: https://security.stackexchange.com/questions/39849/does-bcrypt-have-a-maximum-password-length – Dai Dec 15 '21 at 22:59

1 Answers1

4

The hash collisions are because bcrypt only hashes the first 72 bytes of input (in most implementations).

This is documented in the README for both the bcryptjs and bcrypt npm packages:

bcryptjs:

The maximum input length is 72 bytes (note that UTF8 encoded characters use up to 4 bytes) and the length of generated hashes is 60 characters.

bcrypt:

Per bcrypt implementation, only the first 72 bytes of a string are used. Any extra bytes are ignored when matching passwords. Note that this is not the first 72 characters. It is possible for a string to contain less than 72 characters, while taking up more than 72 bytes (e.g. a UTF-8 encoded string containing emojis).

(That's an objectively terrible design considering this is for user-security... The bcryptjs library really should always throw an exception if the input exceeds 72 bytes IMO)

I note that bcrypt is design for human-supplied (i.e. non-random) passwords, not as a general-purpose message-digest algorithm. Given you don't need to add a salt to randomly-generated passwords (like your refresh_token value) you probably should use something like a SHA-2 family algorithm (e.g. SHA-256, but not SHA-1) for this.

Dai
  • 141,631
  • 28
  • 261
  • 374