Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update crypto library, CryptoJS CVE & deprecation #9350

Merged
merged 27 commits into from
Nov 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4018395
Switch to node:crypto randomBytes generator for secret generator
cannikin Oct 26, 2023
55988e6
Update dbAuth setup to generate key from node:crypto
cannikin Oct 26, 2023
478655c
Remove secure-random-password as a dependency
cannikin Oct 26, 2023
fd00b28
Update hashPassword to use new algorithm, fallback to old algorithm a…
cannikin Oct 26, 2023
3bd1f54
Converts cookie session management to use node:crypto algorithm
cannikin Oct 27, 2023
973f690
Create 32 byte long secrets
cannikin Oct 27, 2023
853ca31
Ports CryptoJS session decryption to node:crypto
cannikin Oct 27, 2023
373e82e
Removes CryptoJS import
cannikin Oct 27, 2023
bfd61ad
Adds type
cannikin Oct 27, 2023
5dda72e
Update graphiql and studio to use encryptSession function
cannikin Oct 27, 2023
0dd2762
Update secret generation tests
cannikin Oct 27, 2023
a4b03c4
Delete SESSION_SECRET completely instead of nullifying
cannikin Oct 27, 2023
e2a8a32
Add function to determine if legacy session cookie
cannikin Oct 27, 2023
8415d57
Re-encrypt session cookie if getToken() is called with a legacy one
cannikin Oct 30, 2023
893f8f9
String.normalize() passwords in case of weird unicode characters
cannikin Oct 30, 2023
a78712c
Merge branch 'main' into rc-dbauth-crypto
cannikin Oct 30, 2023
8152eab
Encodes scrypt difficulty settings into hashed password
cannikin Oct 30, 2023
b1b5e5a
Merge branch 'main' into rc-dbauth-crypto
cannikin Oct 31, 2023
164079f
Added type
cannikin Nov 3, 2023
dde9126
text could be undefined
cannikin Nov 3, 2023
622d792
Need only 32 characters for session secret
cannikin Nov 3, 2023
537c5da
resetTokenExpiresAt not being properly set as DateTime, need to conve…
cannikin Nov 3, 2023
7aed7bf
Revert "resetTokenExpiresAt not being properly set as DateTime, need …
cannikin Nov 3, 2023
4755867
Merge branch 'main' into rc-dbauth-crypto
cannikin Nov 3, 2023
0e8e37d
chore: lint fix; remove TS from JS file to fix parsing
jtoar Nov 3, 2023
7226ae9
remove crypto-js from deps
jtoar Nov 3, 2023
2990e93
try refactoring the authChecks tests
jtoar Nov 3, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: 'rob@redwoodjs.com',
// 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
Loading