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 7 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
48 changes: 34 additions & 14 deletions packages/auth-providers/dbAuth/api/src/DbAuthHandler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import crypto from 'node:crypto'

import type { PrismaClient } from '@prisma/client'
import type {
GenerateAuthenticationOptionsOpts,
Expand All @@ -13,7 +15,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,9 +25,11 @@ import * as DbAuthError from './errors'
import {
cookieName,
decryptSession,
encryptSession,
extractCookie,
getSession,
hashPassword,
legacyHashPassword,
hashToken,
webAuthnSession,
} from './shared'
Expand Down Expand Up @@ -637,11 +640,13 @@ export class DbAuthHandler<

let user = await this._findUserByToken(resetToken as string)
const [hashedPassword] = hashPassword(password, 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 +1159,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,16 +1279,36 @@ export class DbAuthHandler<
)
}

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

async _verifyPassword(user: Record<string, unknown>, password: string) {
const [hashedPassword] = hashPassword(
password,
user[this.options.authFields.salt]
user[this.options.authFields.salt] as string
)

if (hashedPassword === user[this.options.authFields.hashedPassword]) {
return user
}

// fallback to old CryptoJS hashing
const [legacyHashedPassword] = legacyHashPassword(
password,
user[this.options.authFields.salt] as string
)

if (legacyHashedPassword === user[this.options.authFields.hashedPassword]) {
// update user's hash to the new algorithm
await this.dbAccessor.update({
where: { id: user.id },
data: { [this.options.authFields.hashedPassword]: hashedPassword },
})
return user
} else {
throw new DbAuthError.IncorrectPasswordError(
username,
user[this.options.authFields.username] as string,
(this.options.login as LoginFlowOptions)?.errors?.incorrectPassword
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import path from 'node:path'

import CryptoJS from 'crypto-js'
import crypto from 'node:crypto'

import { DbAuthHandler } from '../DbAuthHandler'
import * as dbAuthError from '../errors'
Expand Down Expand Up @@ -81,10 +81,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+=/|]+;/
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 +102,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',
salt: 'ba8b7807c6de6d6a892ef27f4073c603',
...attributes,
},
})
Expand All @@ -119,8 +120,17 @@ 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',
(process.env.SESSION_SECRET as string).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 +139,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 +591,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 +624,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 +635,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 +646,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 +684,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 @@ -2168,11 +2178,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 +2345,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'
)
// salt should remain the same
expect(user.salt).toEqual('2ef27f4073c603ba8b7807c6de6d6a89')
})
})

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