Skip to content

Commit 0d03e30

Browse files
committed
refactor(auth): replace bcrypt with much better password hashing algorithm built into node crypto module for password hashing and validation
refactor(validation): update password schema to reflect scrypt limits refactor(tests): use crypto for password hashing in test utilities
1 parent 282f32c commit 0d03e30

File tree

5 files changed

+53
-26
lines changed

5 files changed

+53
-26
lines changed

app/utils/auth.server.ts

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import crypto from 'node:crypto'
1+
import crypto from 'crypto'
22
import { type Connection, type Password, type User } from '@prisma/client'
3-
import bcrypt from 'bcryptjs'
43
import { redirect } from 'react-router'
54
import { Authenticator } from 'remix-auth'
65
import { safeRedirect } from 'remix-utils/safe-redirect'
@@ -11,6 +10,14 @@ import { type ProviderUser } from './providers/provider.ts'
1110
import { authSessionStorage } from './session.server.ts'
1211
import { uploadProfileImage } from './storage.server.ts'
1312

13+
const SCRYPT_PARAMS = {
14+
N: 2 ** 14,
15+
r: 16,
16+
p: 1,
17+
keyLength: 64,
18+
saltLength: 16,
19+
}
20+
1421
export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
1522
export const getSessionExpirationDate = () =>
1623
new Date(Date.now() + SESSION_EXPIRATION_TIME)
@@ -231,8 +238,9 @@ export async function logout(
231238
}
232239

233240
export async function getPasswordHash(password: string) {
234-
const hash = await bcrypt.hash(password, 10)
235-
return hash
241+
const salt = crypto.randomBytes(SCRYPT_PARAMS.saltLength).toString('hex')
242+
const hash = await generateKey(password, salt)
243+
return `${salt}:${hash.toString('hex')}`
236244
}
237245

238246
export async function verifyUserPassword(
@@ -244,11 +252,16 @@ export async function verifyUserPassword(
244252
select: { id: true, password: { select: { hash: true } } },
245253
})
246254

247-
if (!userWithPassword || !userWithPassword.password) {
248-
return null
249-
}
255+
if (!userWithPassword || !userWithPassword.password) return null
250256

251-
const isValid = await bcrypt.compare(password, userWithPassword.password.hash)
257+
const [salt, key] = userWithPassword.password.hash.split(':')
258+
259+
if (!key || !salt) return null
260+
261+
const isValid = crypto.timingSafeEqual(
262+
Buffer.from(key, 'hex'),
263+
await generateKey(password, salt),
264+
)
252265

253266
if (!isValid) {
254267
return null
@@ -292,3 +305,25 @@ export async function checkIsCommonPassword(password: string) {
292305
return false
293306
}
294307
}
308+
309+
async function generateKey(
310+
password: string,
311+
salt: string,
312+
): Promise<Buffer<ArrayBufferLike>> {
313+
return new Promise<Buffer<ArrayBufferLike>>((resolve, reject) => {
314+
crypto.scrypt(
315+
password.normalize('NFKC'),
316+
salt,
317+
SCRYPT_PARAMS.keyLength,
318+
{
319+
N: SCRYPT_PARAMS.N,
320+
r: SCRYPT_PARAMS.r,
321+
p: SCRYPT_PARAMS.p,
322+
},
323+
(err, key) => {
324+
if (err) reject(err)
325+
else resolve(key)
326+
},
327+
)
328+
})
329+
}

app/utils/user-validation.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,8 @@ export const UsernameSchema = z
1616
export const PasswordSchema = z
1717
.string({ required_error: 'Password is required' })
1818
.min(6, { message: 'Password is too short' })
19-
// NOTE: bcrypt has a limit of 72 bytes (which should be plenty long)
20-
// https://github.com/epicweb-dev/epic-stack/issues/918
21-
.refine((val) => new TextEncoder().encode(val).length <= 72, {
22-
message: 'Password is too long',
23-
})
19+
// scrypt has no such limit unlike bcrypt which has 72 bytes limit
20+
.max(100, { message: 'Password is too long' })
2421

2522
export const NameSchema = z
2623
.string({ required_error: 'Name is required' })

package-lock.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@
7272
"@tailwindcss/vite": "^4.1.5",
7373
"@tusbar/cache-control": "1.0.2",
7474
"address": "^2.0.3",
75-
"bcryptjs": "^3.0.2",
7675
"class-variance-authority": "^0.7.1",
7776
"close-with-grace": "^2.2.0",
7877
"clsx": "^2.1.1",

tests/db-utils.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import crypto from 'crypto'
12
import { faker } from '@faker-js/faker'
2-
import bcrypt from 'bcryptjs'
33
import { UniqueEnforcer } from 'enforce-unique'
44

55
const uniqueUsernameEnforcer = new UniqueEnforcer()
@@ -30,8 +30,14 @@ export function createUser() {
3030
}
3131

3232
export function createPassword(password: string = faker.internet.password()) {
33+
const salt = crypto.randomBytes(16).toString('hex')
34+
const hash = crypto.scryptSync(password, salt, 64, {
35+
N: 2 ** 14,
36+
r: 16,
37+
p: 1,
38+
})
3339
return {
34-
hash: bcrypt.hashSync(password, 10),
40+
hash: `${salt}:${hash.toString('hex')}`,
3541
}
3642
}
3743

0 commit comments

Comments
 (0)