Skip to content

Commit

Permalink
Extract scrypt module
Browse files Browse the repository at this point in the history
  • Loading branch information
emlun committed Aug 17, 2023
1 parent 9a080b6 commit 855e0ee
Show file tree
Hide file tree
Showing 2 changed files with 75 additions and 69 deletions.
76 changes: 7 additions & 69 deletions src/entities/user.entity.ts
Original file line number Diff line number Diff line change
@@ -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" })
Expand Down Expand Up @@ -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<string> {
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<string> {
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<Result<boolean, void>> {
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<UserEntity> = AppDataSource.getRepository(UserEntity);

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
}
});
Expand Down Expand Up @@ -269,4 +207,4 @@ export {
UpdateFcmError,
getUserByUsername,
getAllUsers
}
}
68 changes: 68 additions & 0 deletions src/scrypt.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<Result<boolean, void>> {
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;
}
}

0 comments on commit 855e0ee

Please sign in to comment.