/**
* @module helpers/jrh_crypto
* @author jesse reichler <mouser@donationcoder.com>
* @copyright 5/19/19
* @description
* Collection of helper functions for crypto related stuff
*/
"use strict";
// modules
// for password hashing
const crypto = require("crypto");
// bcrypt crypto helper
const bcrypt = require("bcrypt");
// our helper modules
const jrhMisc = require("./jrh_misc");
//---------------------------------------------------------------------------
// constants
const DefPasswordAlgorithm = "bcrypt";
// const DefPasswordAlgorithm = "crypto_sha512";
// salt length, not used by bcrypt
const DefCryptSaltLength = 16;
// salt rounds, used by bcrypt
const DefCryptSaltRounds = 11;
// update this when you change something about the way a pass algorithm works or the options here,
// so you can easily filter users based using a password version threshold to force updates.
// in addition we store password creation dates, so we can filter based on some date of db compromise
const DefLatestPasswordVersion = 2;
// for humaneasy codes (all uppercase, no I no O no Z no 0 no 1 no 2)
const DefHumanEasyCharactersArray = ["ABCDEFGHJKLMNPQRSTUVWXY", "3456789"];
const DefHumanEasyCharacters = DefHumanEasyCharactersArray[0] + DefHumanEasyCharactersArray[1];
//---------------------------------------------------------------------------
/**
* Take a plaintext string and hash it.
* A random salt is automatically generated and added to the hash object
*
* @param {string} passwordPlaintext
* @returns passwordHashedObj, an object with property fields for the hashed password, including hash (the hashed string), and other meta properties describing the hash operation
*/
async function hashPlaintextPasswordToObj(passwordPlaintext) {
// algorithm to use
const passwordAlgorithm = DefPasswordAlgorithm;
// a salt of "" means to use a random salt and include it in the hash
const salt = "";
// hash it
const passwordHashedObj = await createHashedObjectFromString(passwordPlaintext, passwordAlgorithm, salt, DefCryptSaltRounds, DefLatestPasswordVersion);
// return it -- an OBJECT with properties not just a string
return passwordHashedObj;
}
/**
* Test a plaintext string (user entered password) against a stored passwordHashed object.
* The passwordHashedObj will contain the random salt to use and the algorithm used.
*
* @param {string} passwordPlaintext
* @param {object} passwordHashedObj
* @returns true if they match, false if they don't, or throws ERROR if something else goes wrong (password algorithm not supported, etc.)
*/
async function testPlaintextPassword(passwordPlaintext, passwordHashedObj) {
// see if password matches
// we allow for password stored in db to be blank. in this case we always reject a password as not matching (ie they can't login with password)
// we allow for this case -- sometimes users may have no password set; in this case it
// but note that we WILL allow a check of a blank plaintext password (so if a blank string is hashed and stored as a valid password, we will check and approve that if it matches)
if (jrhMisc.isObjectEmpty(passwordHashedObj)) {
return false;
}
// password obj properties
const passwordAlgorithm = passwordHashedObj.alg;
const passwordHashedStr = passwordHashedObj.hash;
const passwordVersion = passwordHashedObj.ver;
// ok compare
try {
if (passwordAlgorithm === "bcrypt") {
// bcrypt uses its own explicit compare function, that is meant to defeat timing attacks
// note that it will figure out the salt and saltrounds from the actual hash string
const bretv = bcrypt.compare(passwordPlaintext, passwordHashedStr);
return bretv;
}
// for non-bcrypt, we essentially repeat the hash process with the previously used salt and then compare
const salt = passwordHashedObj.salt;
const saltRounds = passwordHashedObj.saltRounds;
//
const passwordHashedTest = await createHashedObjectFromString(passwordPlaintext, passwordAlgorithm, salt, saltRounds, passwordVersion);
// now is the hashed version of the new plaintext the same as the hashed version of the old stored one?
return (passwordHashedTest.passwordHashedStr === passwordHashedStr);
} catch (err) {
const emsg = "Error in jrhMisc exports.testPlaintextPassword while attempting to parse/compare hashed password string with password algorithn '" + passwordAlgorithm + "'";
if (true) {
// throw it up and let caller handle it (adding our more verbos error)
err.message = emsg + "; " + err.message;
}
throw err;
}
// no match
return false;
}
//---------------------------------------------------------------------------
/**
* More specific function to created hashed object from a plaintext string
*
* @param {string} plaintextString
* @param {string} passwordAlgorithm - from bcrypt|crypto_sha512|plain (just returns string itself)
* @param {string} salt - the salt to use, incorporated in return object; if blank a random one is generated
* @param {int} saltRounds - the number of rounds of salting
* @param {int} passwordVersion - password version number; passed to hash function, slows down hashing making brute forcing harder
* @returns hashed object with .hash containing the hashed string with salt info, etc. and other meta properties
*/
async function createHashedObjectFromString(plaintextString, passwordAlgorithm, salt, saltRounds, passwordVersion) {
// function to hash plaintext password and return an object with hashed password properties
let hashedString;
//
if (passwordAlgorithm === "plain") {
hashedString = plaintextString;
salt = "";
} else if (passwordAlgorithm === "bcrypt") {
// bcrypt module hash -- the most widely recommended method
// note that bcrypt does not let us specify salt, and embeds extra info in hashedString string
hashedString = await bcrypt.hash(plaintextString, saltRounds);
// null these so we dont save them (saltRound info is embedded in the bcrypt hash)
salt = null;
saltRounds = null;
} else if (passwordAlgorithm === "crypto_sha512") {
// crypto module hash
// see https://ciphertrick.com/2016/01/18/salt-hash-passwords-using-nodejs-crypto/
// note: crypto does not use saltRounds
if (!salt) {
// no salt provided, make a random one
salt = generateRandomSalt();
}
//
const hash = crypto.createHmac("sha512", salt);
hash.update(plaintextString);
hashedString = hash.digest("hex");
// null these so we dont save them
saltRounds = null;
} else {
throw (new Error("Uknown password hash algorithm: " + passwordAlgorithm));
}
// build the passwordHashed and return it
const hashedObj = {
hash: hashedString,
alg: passwordAlgorithm,
// version is a numeric value we can use in case we need to force upgrade everyone with an old password algorithm, etc.
ver: passwordVersion,
// save date so we can find older passwords we want to force users to update after some issue
date: new Date(),
};
if (salt !== null) {
hashedObj.salt = salt;
}
if (saltRounds !== null) {
hashedObj.saltRounds = saltRounds;
}
//
return hashedObj;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Generate a random salt of a default langth (DefCryptSaltLength const)
* @returns hex string of length DefCryptSaltLength
*/
function generateRandomSalt() {
// private func
return genRandomStringHex(DefCryptSaltLength);
}
/**
* Generate a random hex string of a specified length, cryptographically random bytes used as data
* @see <a href="https://ciphertrick.com/2016/01/18/salt-hash-passwords-using-nodejs-crypto/">salting hash passwords</a>
*
* @param {int} length - the number of characters
* @returns random string of characters of specified length
*/
function genRandomStringHex(length) {
return crypto.randomBytes(Math.ceil(length / 2))
.toString("hex")
.slice(0, length);
}
/**
* This generates a random string using only characters and digits that are easy for humans to recognize and differentiate
* @see <a href="https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript">stackoverflow</a>
* ##### Notes
* * This is not cryptographically secure random numbers, as it uses Math.random
* @todo Security: Replace with crypto secure prng?
*
* @param {int} length
* @returns random string of specified characters consisting of only characters and digits found in DefHumanEasyCharacters
*/
function genRandomStringHumanEasy(length) {
// generate a string of letters and numbers that is hard for humans to mistake
// so all uppercase and avoid letters that could be duplicates
// see https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript
let retstr = "";
const charlen = DefHumanEasyCharacters.length;
let charpos;
for (let i = 0; i < length; i++) {
charpos = Math.floor(Math.random() * charlen);
retstr += DefHumanEasyCharacters.charAt(charpos);
}
return retstr;
}
/**
* This generates a random string using only characters and digits that are easy for humans to recognize and differentiate,
* and also alternates numbers and digits for even easier to remember codes.
* @see <a href="https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript">stackoverflow</a>
* ##### Notes
* * This is not cryptographically secure random numbers, as it uses Math.random
* @todo Security: Replace with crypto secure prng?
*
* @param {int} length
* @returns random string consisting of only characters and digits found in DefHumanEasyCharacters
*/
function genRandomStringHumanEasier(length) {
let retstr = "";
let charlen, charpos;
let group = 0;
for (let i = 0; i < length; i++) {
if (group > 1) {
// alternate
group = 0;
}
charlen = DefHumanEasyCharactersArray[group].length;
charpos = Math.floor(Math.random() * charlen);
retstr += DefHumanEasyCharactersArray[group].charAt(charpos);
group++;
}
return retstr;
}
/**
* Generate a random string of characters from a character set, of the length specified
*
* @param {string} charset
* @param {int} length
* @returns the random string
*/
function genRandomStringFromCharSet(charset, length) {
const charlen = charset.length;
let retstr = "";
let charpos;
for (let i = 0; i < length; i++) {
charpos = Math.floor(Math.random() * charlen);
retstr += charset.charAt(charpos);
}
return retstr;
}
//---------------------------------------------------------------------------
//---------------------------------------------------------------------------
/**
* Hash a string, but this time using a specific salt, returning a simple hashed string as result.
* ##### Notes
* * This function needs to retun the SAME HASH no matter when we call it, so that we can search for result. This means we dont use a random salt
* * And we always use sha51 algorithm.
* * This helper function is used to hash verification codes in database so that if db is compromised it will be harder to retrieve plaintext verificaiton code
* * We can't use random salt because we need to be able to look up matching items by the hashed version.
* @todo In future we might use a two-part verification code, where first part is unique plaintext id, and second part is hashed string; in that way we could look up items by their plaintext part, and use any crypto for the hashed part.
*
* @param {string} plaintextString
* @param {string} salt
* @returns hashed string
*/
async function hashPlaintextStringInsecureButSearchable(plaintextString, salt) {
const hash = crypto.createHmac("sha512", salt);
hash.update(plaintextString);
const hashedString = hash.digest("hex");
return hashedString;
}
//---------------------------------------------------------------------------
module.exports = {
hashPlaintextPasswordToObj,
testPlaintextPassword,
genRandomStringHumanEasy,
genRandomStringHumanEasier,
genRandomStringFromCharSet,
hashPlaintextStringInsecureButSearchable,
};