node-scrypt-wrapper/index.js

250 lines
7.6 KiB
JavaScript

/**
* 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<string>}
*/
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<string>}
*/
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<bool>} 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<bool>} 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,
}