Something went wrong on our end
-
Louis authored9171be33
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
}