Skip to content
Snippets Groups Projects
crypto.js 9.40 KiB
/** @module core/utils/crypto */

const crypto = require('crypto')
const scrypt = require('scrypt-kdf')
const { config } = require('bootstrap')

exports.secureBuffer = function generateSecureBuffer(bytes) {
	return new Promise((resolve, reject) => {
		crypto.randomBytes(bytes, (err, buffer) => {
			if (err) {
				reject(err)
			} else {
				resolve(buffer)
			}
		})
	})
}

/**
 * Generate a cryptographically secure hexadecimal string with the specified number of
 * bytes. The generated value is secure enough to be used for short lived applications
 * such as request nonces and reset tokens, but should not be used for higher security
 * requirements such as passwords
 *
 * @param {number} bytes The length of the desired string in bytes. The string length
 * will be double the number of bytes specified, as each byte will be mapped to two
 * hexadecimal characters.
 *
 * For example, to get a nonce of length 32, you should request a hex string comprising
 * 16 bytes; `crypto.secureHexString(16) // Promise -> output.length === 32`
 *
 * @returns {Promise<string>} A hexadecimal string with length `bytes * 2`. Created by
 * converting a buffer of randomly generated bytes into a hex string
 */
exports.secureHexString = function generateSecureHexString(bytes) {
	return exports.secureBuffer(bytes).then(b => b.toString('hex'))
}

const hex = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f']

/**
 * Generate a hexadecimal string of the given length in a synchronous and insecure manner.
 * The return value of this function should not be used in a secure context, but can be
 * used to easily generate a unique identifier in scenarios where security is not important
 * and synchronous execution is a requirement
 *
 * @param {number} length The number of characters to generate. Unlike
 * the {@link module:core/utils/crypto.secureHexString|secureHexString} function, the length
 * specified is the exact length of the output string.
 *
 * @returns {string} A string of hexadecimal characters. Not guaranteed to be secure or insecure
 */
exports.insecureHexString = function generateInsecureHexStringSync(length) {
	const buffer = []
	for (let i = 0; i < length; i += 1) {
		const char = hex[Math.floor(Math.random() * hex.length)]
		buffer.push(char)
	}
	return buffer.join('')
}

/**
 * The "Friendly" alphabet contains no characters that could easily be confused with one another; for example, there is
 * no capital or lowercase 'o', only 0. upper and lowercase i are also omitted so as not to be confused with a 1.
 * The numbers are also positioned in the last 10 spaces so that any character selection can easily subset with just
 * letters by reducing the index range by 10
 *
 * @type {string[]}
 */
const friendlyAlphabet = [
	'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
	'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
	'1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
]
exports.friendlyRefString = function generateFriendlyRefString(length) {
	const buffer = []
	for (let i = 0; i < length; i += 1) {
		if (i === 0) {
			const value = Math.floor(Math.random() * (friendlyAlphabet.length - 10)) // Never start with a number
			buffer.push(friendlyAlphabet[value])
		} else {
			const value = Math.floor(Math.random() * friendlyAlphabet.length)
			buffer.push(friendlyAlphabet[value])
		}
	}
	return buffer.join('')
}

function getScryptParams() {
	return scrypt.pickParams(0.2)
}

exports.scryptParams = getScryptParams

/**
 * Take a value and stringify it.
 *
 * This implementation will attempt to use `JSON.stringify` if
 * the payload contains an implementation of `#toJSON`, otherwise
 * the payload's `#toString` method will be called to stringify
 * it. It is assumed that the string representation will be a
 * valid UTF-8 string
 *
 * @param {*} payload The value to stringify. Can be anything that
 * has a reasonable string representation (i.e. objects are OK,
 * functions are not)
 *
 * @returns {string} A string that represents the payload in some
 * manner. No guarantee is made about symmetric serialization, so
 * you should not assume that you can reconstruct the payload from
 * this return value.
 */
function stringifyPayload(payload) {
	let source = null
	if (payload.hasOwnProperty('toJSON')) {
		source = JSON.stringify(source)
	} else {
		source = payload.toString()
	}
	return source
}

/**
 * Create a cryptographically secure hash from the given payload. The
 * payload will be converted to a string before being hashed, so a
 * custom toString or toJSON implementation would be recommended
 *
 * Be aware that this function is currently backed by Scrypt, but could
 * change in future as part of a breaking change, and any persisted
 * hashes should be migrated at such time
 *
 * @param {*} payload The value to hash. Refer to
 * {@link module:core/utils/crypto~stringifyPayload} for information
 * regarding how it will be serialised
 *
 * @returns {Promise<string>} A promise that will resolve to the base64
 * representation of the hashed payload value
 */
exports.hash = function hashPayload(payload) {
	const source = stringifyPayload(payload)
	const inputBuffer = Buffer.from(source, 'utf-8')
	const params = getScryptParams()
	return scrypt.kdf(inputBuffer, params)
		.then(hash => hash.toString('base64'))
}

/**
 * Verifies that a hash matches the given payload. if the payload
 * is an object, it is recommended that the object's stringification
 * method (see related hash method) returns values in a deterministic
 * order, otherwise verification may fail for two matching objects.
 *
 * @param {string} hash A previously computed hash value, provided in
 * the form of a base64 encoded string
 *
 * @param {*} payload The payload to check against. Refer to
 * {@link module:core/utils/crypto~stringifyPayload} for information
 * regarding how it will be serialised
 *
 * @returns {Promise<boolean>} Whether or not the payload matches the hash
 *
 * @see module:core/utils/crypto~stringifyPayload
 */
exports.verify = function verifyPayload(hash, payload) {
	const source = stringifyPayload(payload)
	const inputBuffer = Buffer.from(source, 'utf-8')
	const hashBuffer = Buffer.from(hash, 'base64')
	return scrypt.verify(hashBuffer, inputBuffer)
}

/**
 * The value used to separate an encrypted payload from its initialisation
 * vector. As encrypted values and IVs are serialised to hex strings,
 * this padding will consist exclusively of URL-safe non-hex characters
 *
 * @type {string}
 * @private
 */
const URL_SAFE_PADDING = '+!n+'

/**
 * Takes a value and encrypts it using the apps's key. If the app
 * key changes, any encrypted values will be rendered un-decryptable
 * via this implementation
 *
 * No initialisation vector needs to be provided for the cipher, as
 * this implementation will generate a nonce of the correct length
 * and append it to the generated value, using the URL_SAFE_PADDING
 * separator
 *
 * @param {*} payload The value to encrypt. Refer to
 * {@link module:core/utils/crypto~stringifyPayload} for information
 * regarding how it will be serialised
 *
 * @returns {Promise<string>} A promise that will resolve to the
 * hex-encoded encrypted payload. May reject with an error if
 * encryption failed
 */
exports.encrypt = function encryptWithAppKey(payload) {
	return exports.encryptWith(config('app.key'), payload)
}

exports.encryptWith = async function encryptWithKey(key, payload) {
	const nonce = await exports.secureBuffer(16)
	const cipher = crypto.createCipheriv('aes-256-ctr', key, nonce)
	const source = stringifyPayload(payload)

	let buffer = cipher.update(source, 'utf8', 'hex')
	buffer += cipher.final('hex')
	buffer += URL_SAFE_PADDING
	buffer += nonce.toString('hex')

	return buffer
}

/**
 * Takes a value encrypted by {@link module:core/utils/crypto.encrypt|encrypt}
 * and attempts to decrypt it. If the app key has changed since the encrypted
 * value was generated, decryption will fail.
 *
 * @param {string} encrypted A value generated by the encrypt function. It is
 * assumed that the nonce is appended to the end of `encrypted` by the
 * {@link module:core/utils/crypto~URL_SAFE_PADDING|URL_SAFE_PADDING} string. If this value has changed since the encrypted
 * string was generated, or is not present, decryption will fail.
 *
 * @returns {string} The stringified version of the encrypted value, decrypted.
 * if a value was provided to `encrypt` after being `JSON.stringify`'d, it is
 * safe to run `JSON.parse` against the return value. For other cases, you
 * should refer to the serialization guarantees of the object you encrypted
 *
 * @throws {Error} If the nonce is not provided, or the input was encrypted with
 * a different {@link module:core/utils/crypto~URL_SAFE_PADDING|URL_SAFE_PADDING}
 * value to the one currently in use
 *
 * @throws {Error} If the application key or encryption algorithm has changed
 * since the input was encrypted
 */
exports.decrypt = function decryptWithAppKey(encrypted) {
	return exports.decryptWith(config('app.key'), encrypted)
}

exports.decryptWith = function decryptWithKey(key, encrypted) {
	const [payload, nonce] = encrypted.split(URL_SAFE_PADDING)

	if (nonce == null) {
		throw new Error('Invalid encrypted payload, missing nonce')
	}

	const nonceBuffer = Buffer.from(nonce, 'hex')
	const decipher = crypto.createDecipheriv('aes-256-ctr', key, nonceBuffer)

	let buffer = decipher.update(payload, 'hex', 'utf8')
	buffer += decipher.final('utf8')

	return buffer
}