diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index bfa1c99..42a53e7 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -2,6 +2,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"; + + @Entity({ name: "user" }) class UserEntity { @PrimaryGeneratedColumn() @@ -52,6 +55,69 @@ 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<{ digest: Buffer, salt: Buffer, keyLen: number, cost: number, blockSize: number }, void> { + try { + if (!(passwordHash.startsWith("$") && passwordHash.endsWith("$"))) { + 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]); + const digest = base64url.toBuffer(splits[4]); + return Ok({ digest, 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) { + 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); @@ -99,18 +165,39 @@ async function getUserByDID(did: string): Promise async function getUserByCredentials(username: string, password: string): Promise> { try { + return await userRepository.manager.transaction(async (manager) => { + const user = await manager.findOne(UserEntity, { where: { username } }); + if (user) { + const scryptRes = await verifyScryptHash(password, user.passwordHash); + if (scryptRes.ok) { + if (scryptRes.val) { + return Ok(user); + } else { + return Err(GetUserErr.NOT_EXISTS); + } - const passwordHash = crypto.createHash('sha256').update(password).digest('base64'); - const res = await AppDataSource.getRepository(UserEntity) - .createQueryBuilder("user") - .where("user.username = :username and user.passwordHash = :passwordHash", { username: username, passwordHash: passwordHash }) - .getOne(); - if (!res) { - return Err(GetUserErr.NOT_EXISTS); - } - return Ok(res); - } - catch(e) { + } else { + // User isn't migrated to sha256 yet - fall back to sha256 + const sha256Hash = crypto.createHash('sha256').update(password).digest('base64'); + + if (user.passwordHash === sha256Hash) { + // Upgrade the user to scrypt + user.passwordHash = await createScryptHash(password); + await manager.save(user); + + return Ok(user); + } else { + return Err(GetUserErr.NOT_EXISTS); + } + } + + } else { + // Compute a throwaway hash anyway so we don't leak timing information + await createScryptHash(password); + return Err(GetUserErr.NOT_EXISTS); + } + }); + } catch (e) { console.log(e); return Err(GetUserErr.DB_ERR) }