224 lines
8 KiB
JavaScript
224 lines
8 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
|
||
|
}
|