Skip to content

Commit

Permalink
Update crypto library, CryptoJS CVE & deprecation (redwoodjs#9350)
Browse files Browse the repository at this point in the history
So CryptoJS just dropped a bomb: everything they do by default is not as
strong as it could be. Oh and by the way, the entire library is now
deprecated. :(
GHSA-xwcq-pm8m-c4vf
Unfortunately we can't just upgrade to the latest release 4.2.0 because
the hashing algorithm has changed, and a user would no longer be able to
login: the default hash generated by CryptoJS 4.2.0 won't match the hash
generated by CryptoJS 4.1.0.

Note that this is only an issue if someone got the contents of your
database and wanted to figure out user passwords (but it still cost
[$45,000 per password](https://eprint.iacr.org/2020/014.pdf)
apparently?).

In the wake of this CVE we're going to convert dbAuth to use the
built-in `node:crypto` library instead, with more sensible default
configuration.

There are two areas where we use the crypto libs:

1. Hashing the user's password to store in the DB and compare on login
2. Encrypting/decrypting the session data in a cookie

We're going to do this in a non-breaking way by supporting *both* the
original CryptoJS-derived values, and the new `node:crypto` ones. The
alternative would be to require every user to change their password,
which seems like a non-starter.

1. On signup, store the hashedPassword using the new `node:crypto`
algorithm
2. On login, compare the user's hashedPassword using the `node:crypto`
algorithm:
  * If a match is found, user is logged in
* If a match fails, fall back to the original CryptoJS algorithm (but
using the `node:crypto` implementation)
* If a match is found, update the `hashedPassword` in the database to
the new algorithm, user is logged in
* If a match is still not found, the user entered the wrong password.

Likewise for cookies and login:

1. When encrypting the user's session, always use the new `node:crypto`
algorithm
2. When decrypting the user's session, first try with `node:crypto`
  * If decrypting works, user is logged in
* If decrypting fails, try the older CryptoJS algorithm ([I haven't
figured how](brix/crypto-js#468) to use
`node:crypto` to decrypt something that was encrypted with CryptoJS yet,
so we'll need to keep the dependency on CryptoJS around for now)
* If decrypting works, re-encrypt the cookie using the new `node:crypto`
algorithm, user is logged in
* If decrypting still fails, the session is invalid (someone tampered
with the cookie) so log them out

## Notifying Users

We could announce in the Release Notes that if a platform wants the
absolute safest route, they should change their `SESSION_SECRET` *and*
have users change their password if, for example, they suspect that
their database may have been compromised before our release.

The next most secure thing would be to just change `SESSION_SECRET`
which would log everyone out, and on next login their password will get
re-hashed with the new algorithm.

But the default for most people will probably be to just go about
business as usual, and as time goes by more and more users' passwords
will be re-hashed on login.

Related to redwoodjs#9337 redwoodjs#9338 redwoodjs#9339 redwoodjs#9340

---------

Co-authored-by: Dominic Saadi <[email protected]>
  • Loading branch information
cannikin and jtoar authored Nov 4, 2023
1 parent 656a2c8 commit c80df9b
Show file tree
Hide file tree
Showing 18 changed files with 581 additions and 316 deletions.
2 changes: 0 additions & 2 deletions packages/auth-providers/dbAuth/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"@redwoodjs/project-config": "6.0.7",
"base64url": "3.0.1",
"core-js": "3.32.2",
"crypto-js": "4.1.1",
"md5": "2.3.0",
"uuid": "9.0.0"
},
Expand All @@ -35,7 +34,6 @@
"@babel/core": "^7.22.20",
"@redwoodjs/api": "6.0.7",
"@simplewebauthn/server": "7.3.1",
"@types/crypto-js": "4.1.1",
"@types/md5": "2.3.2",
"@types/uuid": "9.0.2",
"jest": "29.7.0",
Expand Down
98 changes: 69 additions & 29 deletions packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import type {
} from '@simplewebauthn/typescript-types'
import type { APIGatewayProxyEvent, Context as LambdaContext } from 'aws-lambda'
import base64url from 'base64url'
import CryptoJS from 'crypto-js'
import md5 from 'md5'
import { v4 as uuidv4 } from 'uuid'

Expand All @@ -24,11 +23,15 @@ import * as DbAuthError from './errors'
import {
cookieName,
decryptSession,
encryptSession,
extractCookie,
getSession,
hashPassword,
legacyHashPassword,
isLegacySession,
hashToken,
webAuthnSession,
extractHashingOptions,
} from './shared'

type SetCookieHeader = { 'set-cookie': string }
Expand Down Expand Up @@ -569,11 +572,15 @@ export class DbAuthHandler<
async getToken() {
try {
const user = await this._getCurrentUser()
let headers = {}

// need to return *something* for our existing Authorization header stuff
// to work, so return the user's ID in case we can use it for something
// in the future
return [user[this.options.authFields.id]]
// if the session was encrypted with the old algorithm, re-encrypt it
// with the new one
if (isLegacySession(this.cookie)) {
headers = this._loginResponse(user)[1]
}

return [user[this.options.authFields.id], headers]
} catch (e: any) {
if (e instanceof DbAuthError.NotLoggedInError) {
return this._logoutResponse()
Expand Down Expand Up @@ -636,12 +643,16 @@ export class DbAuthHandler<
}

let user = await this._findUserByToken(resetToken as string)
const [hashedPassword] = hashPassword(password, user.salt)
const [hashedPassword] = hashPassword(password, {
salt: user.salt,
})
const [legacyHashedPassword] = legacyHashPassword(password, user.salt)

if (
!(this.options.resetPassword as ResetPasswordFlowOptions)
(!(this.options.resetPassword as ResetPasswordFlowOptions)
.allowReusedPassword &&
user.hashedPassword === hashedPassword
user.hashedPassword === hashedPassword) ||
user.hashedPassword === legacyHashedPassword
) {
throw new DbAuthError.ReusedPasswordError(
(
Expand Down Expand Up @@ -1154,21 +1165,16 @@ export class DbAuthHandler<
return meta
}

// encrypts a string with the SESSION_SECRET
_encrypt(data: string) {
return CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET as string)
}

// returns the set-cookie header to be returned in the request (effectively
// creates the session)
_createSessionHeader<TIdType = any>(
data: DbAuthSession<TIdType>,
csrfToken: string
): SetCookieHeader {
const session = JSON.stringify(data) + ';' + csrfToken
const encrypted = this._encrypt(session)
const encrypted = encryptSession(session)
const cookie = [
`${cookieName(this.options.cookie?.name)}=${encrypted.toString()}`,
`${cookieName(this.options.cookie?.name)}=${encrypted}`,
...this._cookieAttributes({ expires: this.sessionExpiresDate }),
].join(';')

Expand Down Expand Up @@ -1279,19 +1285,56 @@ export class DbAuthHandler<
)
}

// is password correct?
const [hashedPassword, _salt] = hashPassword(
password,
user[this.options.authFields.salt]
await this._verifyPassword(user, password)
return user
}

// extracts scrypt strength options from hashed password (if present) and
// compares the hashed plain text password just submitted using those options
// with the one in the database. Falls back to the legacy CryptoJS algorihtm
// if no options are present.
async _verifyPassword(user: Record<string, unknown>, password: string) {
const options = extractHashingOptions(
user[this.options.authFields.hashedPassword] as string
)
if (hashedPassword === user[this.options.authFields.hashedPassword]) {
return user

if (Object.keys(options).length) {
// hashed using the node:crypto algorithm
const [hashedPassword] = hashPassword(password, {
salt: user[this.options.authFields.salt] as string,
options,
})

if (hashedPassword === user[this.options.authFields.hashedPassword]) {
return user
}
} else {
throw new DbAuthError.IncorrectPasswordError(
username,
(this.options.login as LoginFlowOptions)?.errors?.incorrectPassword
// fallback to old CryptoJS hashing
const [legacyHashedPassword] = legacyHashPassword(
password,
user[this.options.authFields.salt] as string
)

if (
legacyHashedPassword === user[this.options.authFields.hashedPassword]
) {
const [newHashedPassword] = hashPassword(password, {
salt: user[this.options.authFields.salt] as string,
})

// update user's hash to the new algorithm
await this.dbAccessor.update({
where: { id: user.id },
data: { [this.options.authFields.hashedPassword]: newHashedPassword },
})
return user
}
}

throw new DbAuthError.IncorrectPasswordError(
user[this.options.authFields.username] as string,
(this.options.login as LoginFlowOptions)?.errors?.incorrectPassword
)
}

// gets the user from the database and returns only its ID
Expand Down Expand Up @@ -1351,8 +1394,7 @@ export class DbAuthHandler<
)
}

// if we get here everything is good, call the app's signup handler and let
// them worry about scrubbing data and saving to the DB
// if we get here everything is good, call the app's signup handler
const [hashedPassword, salt] = hashPassword(password)
const newUser = await (this.options.signup as SignupFlowOptions).handler({
username,
Expand Down Expand Up @@ -1406,9 +1448,7 @@ export class DbAuthHandler<
] {
const sessionData = { id: user[this.options.authFields.id] }

// TODO: this needs to go into graphql somewhere so that each request makes
// a new CSRF token and sets it in both the encrypted session and the
// csrf-token header
// TODO: this needs to go into graphql somewhere so that each request makes a new CSRF token and sets it in both the encrypted session and the csrf-token header
const csrfToken = DbAuthHandler.CSRF_TOKEN

return [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import crypto from 'node:crypto'
import path from 'node:path'

import CryptoJS from 'crypto-js'

import { DbAuthHandler } from '../DbAuthHandler'
import * as dbAuthError from '../errors'
import { hashToken } from '../shared'
Expand Down Expand Up @@ -81,10 +80,10 @@ const db = new DbMock(['user', 'userCredential'])

const UUID_REGEX =
/\b[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}\b/
const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]+;/
const SET_SESSION_REGEX = /^session=[a-zA-Z0-9+=/]|[a-zA-Z0-9+=/]+;/
const UTC_DATE_REGEX = /\w{3}, \d{2} \w{3} \d{4} [\d:]{8} GMT/
const LOGOUT_COOKIE = 'session=;Expires=Thu, 01 Jan 1970 00:00:00 GMT'

const SESSION_SECRET = '540d03ebb00b441f8f7442cbc39958ad'
const FIXTURE_PATH = path.resolve(
__dirname,
'../../../../../../__fixtures__/example-todo-main'
Expand All @@ -102,9 +101,10 @@ const createDbUser = async (attributes = {}) => {
return await db.user.create({
data: {
email: '[email protected]',
// default hashedPassword is from `node:crypto`
hashedPassword:
'0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba',
salt: '2ef27f4073c603ba8b7807c6de6d6a89',
'230847bea5154b6c7d281d09593ad1be26fa03a93c04a73bcc2b608c073a8213|16384|8|1',
salt: 'ba8b7807c6de6d6a892ef27f4073c603',
...attributes,
},
})
Expand All @@ -119,7 +119,16 @@ const expectLoggedInResponse = (response) => {
}

const encryptToCookie = (data) => {
return `session=${CryptoJS.AES.encrypt(data, process.env.SESSION_SECRET)}`
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(
'aes-256-cbc',
SESSION_SECRET.substring(0, 32),
iv
)
let encryptedSession = cipher.update(data, 'utf-8', 'base64')
encryptedSession += cipher.final('base64')

return `session=${encryptedSession}|${iv.toString('base64')}`
}

let event, context, options
Expand All @@ -129,7 +138,7 @@ describe('dbAuth', () => {
// hide deprecation warnings during test
jest.spyOn(console, 'warn').mockImplementation(() => {})
// encryption key so results are consistent regardless of settings in .env
process.env.SESSION_SECRET = 'nREjs1HPS7cFia6tQHK70EWGtfhOgbqJQKsHQz3S'
process.env.SESSION_SECRET = SESSION_SECRET
delete process.env.DBAUTH_COOKIE_DOMAIN

event = {
Expand Down Expand Up @@ -581,7 +590,7 @@ describe('dbAuth', () => {
event = {
headers: {
cookie:
'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx',
'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w==',
},
}
const dbAuth = new DbAuthHandler(event, context, options)
Expand Down Expand Up @@ -614,7 +623,7 @@ describe('dbAuth', () => {
event.body = JSON.stringify({ method: 'logout' })
event.httpMethod = 'GET'
event.headers.cookie =
'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx'
'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w=='
const dbAuth = new DbAuthHandler(event, context, options)
const response = await dbAuth.invoke()

Expand All @@ -625,7 +634,7 @@ describe('dbAuth', () => {
event.body = JSON.stringify({ method: 'foobar' })
event.httpMethod = 'POST'
event.headers.cookie =
'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx'
'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w=='
const dbAuth = new DbAuthHandler(event, context, options)
const response = await dbAuth.invoke()

Expand All @@ -636,7 +645,7 @@ describe('dbAuth', () => {
event.body = JSON.stringify({ method: 'logout' })
event.httpMethod = 'POST'
event.headers.cookie =
'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx'
'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w=='
const dbAuth = new DbAuthHandler(event, context, options)
dbAuth.logout = jest.fn(() => {
throw Error('Logout error')
Expand Down Expand Up @@ -674,7 +683,7 @@ describe('dbAuth', () => {
event.body = JSON.stringify({ method: 'logout' })
event.httpMethod = 'POST'
event.headers.cookie =
'session=U2FsdGVkX1/zRHVlEQhffsOufy7VLRAR6R4gb818vxblQQJFZI6W/T8uzxNUbQMx'
'session=ko6iXKV11DSjb6kFJ4iwcf1FEqa5wPpbL1sdtKiV51Y=|cQaYkOPG/r3ILxWiFiz90w=='
const dbAuth = new DbAuthHandler(event, context, options)
dbAuth.logout = jest.fn(() => ['body', { foo: 'bar' }])
const response = await dbAuth.invoke()
Expand Down Expand Up @@ -1595,6 +1604,27 @@ describe('dbAuth', () => {

expect(response[0]).toEqual('{"error":"User not found"}')
})

it('re-encrypts the session cookie if using the legacy algorithm', async () => {
await createDbUser({ id: 7 })
event = {
headers: {
// legacy session with { id: 7 } for userID
cookie: 'session=U2FsdGVkX1+s7seQJnVgGgInxuXm13l8VvzA3Mg2fYg=',
},
}
process.env.SESSION_SECRET =
'QKxN2vFSHAf94XYynK8LUALfDuDSdFowG6evfkFX8uszh4YZqhTiqEdshrhWbwbw'

const dbAuth = new DbAuthHandler(event, context, options)
const [userId, headers] = await dbAuth.getToken()

expect(userId).toEqual(7)
expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX)

// set session back to default
process.env.SESSION_SECRET = SESSION_SECRET
})
})

describe('When a developer has set GraphiQL headers to mock a session cookie', () => {
Expand Down Expand Up @@ -2168,11 +2198,11 @@ describe('dbAuth', () => {
`Expires=${dbAuth.sessionExpiresDate}`
)
// can't really match on the session value since it will change on every render,
// due to CSRF token generation but we can check that it contains a only the
// characters that would be returned by the hash function
// due to CSRF token generation but we can check that it contains only the
// characters that would be returned by the encrypt function
expect(headers['set-cookie']).toMatch(SET_SESSION_REGEX)
// and we can check that it's a certain number of characters
expect(headers['set-cookie'].split(';')[0].length).toEqual(72)
expect(headers['set-cookie'].split(';')[0].length).toEqual(77)
})
})

Expand Down Expand Up @@ -2335,6 +2365,38 @@ describe('dbAuth', () => {

expect(user.id).toEqual(dbUser.id)
})

it('returns the user if password is hashed with legacy algorithm', async () => {
const dbUser = await createDbUser({
// CryptoJS hashed password
hashedPassword:
'0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba',
salt: '2ef27f4073c603ba8b7807c6de6d6a89',
})
const dbAuth = new DbAuthHandler(event, context, options)
const user = await dbAuth._verifyUser(dbUser.email, 'password')

expect(user.id).toEqual(dbUser.id)
})

it('updates the user hashPassword to the new algorithm', async () => {
const dbUser = await createDbUser({
// CryptoJS hashed password
hashedPassword:
'0c2b24e20ee76a887eac1415cc2c175ff961e7a0f057cead74789c43399dd5ba',
salt: '2ef27f4073c603ba8b7807c6de6d6a89',
})
const dbAuth = new DbAuthHandler(event, context, options)
await dbAuth._verifyUser(dbUser.email, 'password')
const user = await db.user.findFirst({ where: { id: dbUser.id } })

// password now hashed by node:crypto
expect(user.hashedPassword).toEqual(
'f20d69d478fa1afc85057384e21bd457a76b23b23e2a94f5bd982976f700a552|16384|8|1'
)
// salt should remain the same
expect(user.salt).toEqual('2ef27f4073c603ba8b7807c6de6d6a89')
})
})

describe('_getCurrentUser()', () => {
Expand Down
Loading

0 comments on commit c80df9b

Please sign in to comment.