/** * Helpers to make using node's built-in scrypt implementation more * straightforward to use and testable * * See https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback * https://stackoverflow.com/questions/62908969/password-hashing-in-nodejs-using-built-in-crypto * */ const { scrypt, scryptSync, timingSafeEqual, randomBytes, } = require('node:crypto') /// Node recommends at least 16 at time of writing. 24 is more than that and // also will result in a base64 string which doesn't contain padding, which is // always nice. const DEFAULT_SALT_LENGTH = 24 // Node defaults to 16384 (1 << 14) const DEFAULT_COST = 14 // Not really sure why this is the value everyone is using. const DEFAULT_KEY_LENGTH = 64 /** * @class * @extends {Error} * @classdesc An error thrown when there was a problem encountered while parsing a serialized hash * @property {?Exception} cause An exception which was thrown while parsing this string. * @property {any} malformedString */ class ParseError extends Error { /** * @param {any} malformedString - The string which could not be parsed * @param {?Exception} cause - An error which was raised while trying to parse the string */ constructor(malformedString, cause) { super( `error parsing ${malformedString}, expected hash encoded by @techworkers/scrypt-wrapper`, ) this.cause = cause this.malformedString = malformedString } } /** * @class * @extends {Error} * @classdesc An Error thrown when the given options value passed to hash() or hashSync() is invalid * @property {HashOptions} options The options passed to hash() or hashSync() */ class InvalidOptions extends Error { /** * * @param {HashOptions} options The options which were received that are invalid */ constructor(options) { super( `invalid options were received:\n\tcost: ${options.cost}\n\tkeyLength: ${options.keyLength}\n\tsaltLength: ${options.saltLength}`, ) this.options = options } /** * Check that the given options are actually what they're expected to be * @param {HashOptions} options * @param {?function} reject A function called with the error rather than it being thrown */ static validateOptions(options, reject) { const { cost, keyLength, saltLength } = options // Cost validation if ( !(typeof cost === 'number' || cost instanceof Number) || cost < 13 || !Number.isInteger(cost) ) { const err = new InvalidOptions(options) if (reject) reject(err) else throw err } // keyLength validation if ( !(typeof keyLength === 'number' || keyLength instanceof Number) || !Number.isInteger(keyLength) ) { const err = new InvalidOptions(options) if (reject) reject(err) else throw err } // saltLength validation if ( !(typeof saltLength === 'number' || saltLength instanceof Number) || saltLength < 16 /* (recommended minimum) */ || !Number.isInteger(saltLength) ) { const err = new InvalidOptions(options) if (reject) reject(err) else throw err } } } /** * @typedef HashOptions the optional values which may be passed to hash() and hashSync() * @property {number} cost the number of bits to shift 1 to to get the cost value. Defaults to 14 * @property {number} keyLength how long the generated hash should be. Defaults to 64. * @property {number} saltLength the number of random bytes to use as a salt. Defaults to 24. */ /** * Generate an scrypt hash for the given password. The resulting string is as * follows: * - The base-16 cost value * - a dot ('.') * - the URL-safe base64-encoded salt * - a dot ('.') * - the URL-safe base64-encoded derived hash * * @param {string} password the plain-text password * @param {?HashOptions} options optional settings * @throws {InvalidOptions} if the options aren't within the expected constraints * @returns {Promise} */ function hash(password, options) { const cost = options?.cost ?? DEFAULT_COST const keyLength = options?.keyLength ?? DEFAULT_KEY_LENGTH const saltLength = options?.saltLength ?? DEFAULT_SALT_LENGTH const salt = randomBytes(saltLength) const $cost = 1 << cost return new Promise((resolve, reject) => { InvalidOptions.validateOptions({ cost, keyLength, saltLength }, reject) let $hash = cost.toString(16) + '.' $hash += salt.toString('base64url') + '.' scrypt( password.normalize(), salt, keyLength, { cost: $cost }, (err, key) => { if (err) reject(err) else resolve($hash + key.toString('base64url')) }, ) }) } /** * Like the above hash() function but synchronous. * * @param {string} password the plain-text password * @param {?HashOptions} options optional settings * @throws {InvalidOptions} if the options aren't within the expected constraints * @returns {Promise} */ function hashSync(password, options) { const cost = options?.cost ?? DEFAULT_COST const keyLength = options?.keyLength ?? DEFAULT_KEY_LENGTH const saltLength = options?.saltLength ?? DEFAULT_SALT_LENGTH InvalidOptions.validateOptions({ cost, keyLength, saltLength }) const salt = randomBytes(saltLength) const $cost = 1 << cost let $hash = cost.toString(16) + '.' $hash += salt.toString('base64url') + '.' return ( $hash + scryptSync(password.normalize(), salt, keyLength, { cost: $cost }).toString( 'base64url', ) ) } /** * Verify that the given plaintext password derives the given hash. The hash is * expected to be in the format as encoded by the above hash() function, which * also encodes the cost and salt used to create it. * * @param {string} derivedHash The hash as derived and encoded by the above * hash() function * @param {string} password The plaintext password to compare * @async * @returns {Promise} whether the password matches */ function verify(derivedHash, password) { return new Promise((resolve, reject) => { var $cost, $salt, $key try { ;[$cost, $salt, $key] = derivedHash.split('.') } catch (e) { return reject(new ParseError(derivedHash, e)) } if (!$cost || !$salt || !$key) { return reject(new ParseError(derivedHash)) } var cost, salt, key try { cost = 1 << Number.parseInt($cost, 16) salt = Buffer.from($salt, 'base64url') key = Buffer.from($key, 'base64url') } catch (e) { return reject(new ParseError(derivedHash, e)) } scrypt(password.normalize(), salt, key.length, { cost }, (err, newKey) => { if (err) reject(err) else resolve(timingSafeEqual(key, newKey)) }) }) } /** * Like `verify()`, but synchronous. * * @param {string} derivedHash The hash as derived and encoded by the above * hash() function * @param {string} password The plaintext password to compare * @throws {ParseError} when there's a problem parsing the given hash * @returns {Promise} whether the password matches */ function verifySync(derivedHash, password) { var $cost, $salt, $key try { ;[$cost, $salt, $key] = derivedHash.split('.') } catch (e) { throw new ParseError(derivedHash, e) } if (!$cost || !$salt || !$key) { throw new ParseError(derivedHash) } var cost, salt, key try { cost = 1 << Number.parseInt($cost, 16) salt = Buffer.from($salt, 'base64url') key = Buffer.from($key, 'base64url') } catch (e) { throw new ParseError(derivedHash, e) } const newKey = scryptSync(password.normalize(), salt, key.length, { cost }) return timingSafeEqual(key, newKey) } module.exports = { hash, hashSync, verify, verifySync, ParseError, InvalidOptions, }