From 855e0eef29e33829146bd66f73df41054e244e90 Mon Sep 17 00:00:00 2001 From: Emil Lundberg Date: Thu, 17 Aug 2023 10:01:45 +0200 Subject: [PATCH] Extract scrypt module --- src/entities/user.entity.ts | 76 ++++--------------------------------- src/scrypt.ts | 68 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 69 deletions(-) create mode 100644 src/scrypt.ts diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 64ac2a0..dc2f8d8 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -1,8 +1,9 @@ import { Err, Ok, Result } from "ts-results"; import { Entity, PrimaryGeneratedColumn, Column, Repository} from "typeorm" -import AppDataSource from "../AppDataSource"; import crypto from "node:crypto"; -import base64url from "base64url"; + +import AppDataSource from "../AppDataSource"; +import * as scrypt from "../scrypt"; @Entity({ name: "user" }) @@ -55,69 +56,6 @@ enum UpdateFcmError { DB_ERR = "Failed to update FCM token list" } -// Settings for new password hashes -// Best practice guidelines from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt -const scryptKeyLen: number = 64; -const scryptCost: number = 131072; // 2^17 -const scryptBlockSize: number = 8; -const scryptMaxMem: number = 128 * scryptCost * scryptBlockSize * 2; - -function parseScryptParams(passwordHash: string): Result<{ salt: Buffer, keyLen: number, cost: number, blockSize: number }, void> { - try { - if (!passwordHash.startsWith("$")) { - return Err.EMPTY; - } - - const splits = passwordHash.split('$'); - const keyLen = parseInt(splits[1], 10); - const cost = parseInt(splits[2], 10); - const blockSize = parseInt(splits[3], 10); - const salt = base64url.toBuffer(splits[4]); - return Ok({ salt, keyLen, cost, blockSize }); - - } catch (e) { - return Err.EMPTY; - } -} - -async function computeScrypt(password: string, salt: Buffer, keyLen: number, cost: number, blockSize: number): Promise { - return new Promise((resolve, reject) => { - crypto.scrypt( - Buffer.from(password, "utf8"), - salt, - keyLen, - { cost, blockSize, maxmem: scryptMaxMem }, - (err, derivedKey) => { - if (err) { - console.error("Failed to compute scrypt hash", err); - reject(err); - } else { - const result = "$" + [keyLen, cost, blockSize, base64url.encode(salt), base64url.encode(derivedKey)].join("$"); - resolve(result); - } - }, - ); - }); -} - -async function createScryptHash(password: string): Promise { - return await computeScrypt(password, crypto.randomBytes(32), scryptKeyLen, scryptCost, scryptBlockSize); -} - -/** - * @return Ok(true) if password matches; Ok(false) if password is scrypt-hashed but does not match; Err(void) if password is not scrypt-hashed. - */ -async function verifyScryptHash(password: string, scryptHash: string): Promise> { - const decodeRes = parseScryptParams(scryptHash); - if (decodeRes.ok) { - const { salt, keyLen, cost, blockSize } = decodeRes.val; - const encoded = await computeScrypt(password, salt, keyLen, cost, blockSize); - return Ok(encoded === scryptHash); - } else { - return Err.EMPTY; - } -} - const userRepository: Repository = AppDataSource.getRepository(UserEntity); @@ -168,7 +106,7 @@ async function getUserByCredentials(username: string, password: string): Promise return await userRepository.manager.transaction(async (manager) => { const user = await manager.findOne(UserEntity, { where: { username } }); if (user) { - const scryptRes = await verifyScryptHash(password, user.passwordHash); + const scryptRes = await scrypt.verifyHash(password, user.passwordHash); if (scryptRes.ok) { if (scryptRes.val) { return Ok(user); @@ -182,7 +120,7 @@ async function getUserByCredentials(username: string, password: string): Promise if (user.passwordHash === sha256Hash) { // Upgrade the user to scrypt - user.passwordHash = await createScryptHash(password); + user.passwordHash = await scrypt.createHash(password); await manager.save(user); return Ok(user); @@ -193,7 +131,7 @@ async function getUserByCredentials(username: string, password: string): Promise } else { // Compute a throwaway hash anyway so we don't leak timing information - await createScryptHash(password); + await scrypt.createHash(password); return Err(GetUserErr.NOT_EXISTS); } }); @@ -269,4 +207,4 @@ export { UpdateFcmError, getUserByUsername, getAllUsers -} \ No newline at end of file +} diff --git a/src/scrypt.ts b/src/scrypt.ts new file mode 100644 index 0000000..031d174 --- /dev/null +++ b/src/scrypt.ts @@ -0,0 +1,68 @@ +import crypto from "node:crypto"; +import base64url from "base64url"; +import { Err, Ok, Result } from "ts-results"; + + +// Settings for new password hashes +// Best practice guidelines from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#scrypt +const keyLen: number = 64; +const cost: number = 131072; // 2^17 +const blockSize: number = 8; +const maxmem: number = 128 * cost * blockSize * 2; + + +function parseParams(passwordHash: string): Result<{ salt: Buffer, keyLen: number, cost: number, blockSize: number }, void> { + try { + if (!passwordHash.startsWith("$")) { + return Err.EMPTY; + } + + const splits = passwordHash.split('$'); + const keyLen = parseInt(splits[1], 10); + const cost = parseInt(splits[2], 10); + const blockSize = parseInt(splits[3], 10); + const salt = base64url.toBuffer(splits[4]); + return Ok({ salt, keyLen, cost, blockSize }); + + } catch (e) { + return Err.EMPTY; + } +} + +async function computeScrypt(password: string, salt: Buffer, keyLen: number, cost: number, blockSize: number): Promise { + return new Promise((resolve, reject) => { + crypto.scrypt( + Buffer.from(password, "utf8"), + salt, + keyLen, + { cost, blockSize, maxmem }, + (err, derivedKey) => { + if (err) { + console.error("Failed to compute scrypt hash", err); + reject(err); + } else { + const result = "$" + [keyLen, cost, blockSize, base64url.encode(salt), base64url.encode(derivedKey)].join("$"); + resolve(result); + } + }, + ); + }); +} + +export async function createHash(password: string): Promise { + return await computeScrypt(password, crypto.randomBytes(32), keyLen, cost, blockSize); +} + +/** + * @return Ok(true) if password matches; Ok(false) if password is scrypt-hashed but does not match; Err(void) if password is not scrypt-hashed. + */ +export async function verifyHash(password: string, scryptHash: string): Promise> { + const decodeRes = parseParams(scryptHash); + if (decodeRes.ok) { + const { salt, keyLen, cost, blockSize } = decodeRes.val; + const encoded = await computeScrypt(password, salt, keyLen, cost, blockSize); + return Ok(encoded === scryptHash); + } else { + return Err.EMPTY; + } +}