index.js

/**
 * Crypto building blocks.
 *
 * @module @stellar-fox/cryptops
 * @license Apache-2.0
 */




import {
    array,
    codec,
    func,
    string,
    type,
    utils,
} from "@xcmats/js-toolbox"
import {
    createCipheriv,
    createDecipheriv,
} from "crypto-browserify"
import scrypt from "scrypt-js"
import {
    codec as sjclCodec,
    hash as sjclHash,
    misc as sjclMisc,
} from "sjcl"
import {
    hash as naclHash,
    randomBytes as naclRandomBytes,
    secretbox as naclSecretbox,
} from "tweetnacl"




/**
 * Retrieve 'n' random bytes from CSPRNG pool.
 * Alias for `tweetnacl.randomBytes()`.
 *
 * @function random
 * @see {@link https://bit.ly/tweetnaclrandom}
 * @param {Number} n
 * @returns {Uint8Array}
 */
export const random = naclRandomBytes




/**
 * Compute a `sha256` hash from a given input.
 * Uses `bitwiseshiftleft/sjcl`'s `sha256` implementation.
 *
 * @function sha256
 * @see {@link https://bit.ly/sjclsha256}
 * @see {@link https://bit.ly/toolboxcodec}
 * @see {@link https://bit.ly/toolboxfunc}
 * @param {Uint8Array} input
 * @returns {Uint8Array}
 */
export const sha256 = func.flow(
    codec.bytesToHex,
    sjclCodec.hex.toBits,
    sjclHash.sha256.hash,
    sjclCodec.hex.fromBits,
    codec.hexToBytes,
)




/**
 * Generate 32-byte value. Can be used as salt.
 *
 * @function salt32
 * @returns {Uint8Array}
 */
export const salt32 = () => func.compose(sha256, random)(128)




/**
 * Password-based key-derivation.
 * Uses `pbkdf2` implemented in `bitwiseshiftleft/sjcl`.
 *
 * @function genKey
 * @see {@link https://bit.ly/sjclpbkdf2}
 * @see {@link https://bit.ly/toolboxcodec}
 * @see {@link https://bit.ly/toolboxfunc}
 * @param {Uint8Array} [pass=Uint8Array.from([])] A password to derive key.
 * @param {Uint8Array} [salt=(new Uint8Array(32)).fill(0)]
 * @param {Number} [count=2**12] Difficulty.
 * @returns {Uint8Array}
 */
export const genKey = (
    pass = Uint8Array.from([]),
    salt = (new Uint8Array(32)).fill(0),
    count = 2**12
) =>
    func.pipe(
        sjclMisc.pbkdf2(
            func.compose(sjclCodec.hex.toBits, codec.bytesToHex)(pass),
            func.compose(sjclCodec.hex.toBits, codec.bytesToHex)(salt),
            count
        )
    )(
        sjclCodec.hex.fromBits,
        codec.hexToBytes
    )




/**
 * Compute a `sha512` hash from a given input.
 * Uses `dchest/tweetnacl-js`'s `sha512` implementation.
 *
 * @function sha512
 * @see {@link https://bit.ly/tweetnaclhash}
 * @param {Uint8Array} input
 * @returns {Uint8Array}
 */
export const sha512 = naclHash




/**
 * Generate 64-byte value. Can be used as salt.
 *
 * @function salt64
 * @returns {Uint8Array}
 */
export const salt64 = () => func.compose(sha512, random)(256)




/**
 * Key derivation options object type definition.
 *
 * @typedef {Object} KeyDerivationOptions
 * @property {Number} [count=2**12] Difficulty (CPU/memory cost)
 * @property {Number} [blockSize=8] The block size
 * @property {Number} [parallelization=1] Parallelization cost
 * @property {Number} [derivedKeySize=64] Derived key size in bytes
 * @property {Function} [progressCallback=()=>false]
 */




/**
 * Default values for KeyDerivationOptions.
 *
 * @private
 * @constant {KeyDerivationOptions} defKDO
 */
const defKDO = Object.freeze({
    count: 2**16,        // `N` - the CPU/memory cost
    blockSize: 8,        // `r` - the block size
    parallelization: 1,  // `p` - parallelization cost
    derivedKeySize: 64,  // derived key size in bytes
    progressCallback: (_p) => false,
})




/**
 * Password-based key-derivation.
 * Uses `scrypt` implemented in `ricmoo/scrypt-js`.
 *
 * @async
 * @function deriveKey
 * @see {@link https://bit.ly/scryptjs}
 * @param {Uint8Array} [pass=Uint8Array.from([])] A password to derive key.
 * @param {Uint8Array} [salt=(new Uint8Array(32)).fill(0)]
 * @param {KeyDerivationOptions} [opts={}] @see KeyDerivationOptions
 * @returns {Promise.<Uint8Array>}
 */
export const deriveKey = (
    pass = Uint8Array.from([]),
    salt = (new Uint8Array(64)).fill(0),
    {
        count = defKDO.count,
        blockSize = defKDO.blockSize,
        parallelization = defKDO.parallelization,
        derivedKeySize = defKDO.derivedKeySize,
        progressCallback = defKDO.progressCallback,
    } = {}
) =>
    new Promise(
        (resolve, reject) =>
            scrypt(
                pass, salt,
                count, blockSize, parallelization, derivedKeySize,
                (error, progress, key) => {
                    if (error) return reject(error)
                    if (key) return resolve(Uint8Array.from(key))
                    if (progress) return progressCallback(progress)
                    return false
                }
            )
    )




/**
 * Generate 48 bits (6 bytes) timestamp - milliseconds since epoch.
 *
 * @function timestamp
 * @returns {Uint8Array}
 */
export const timestamp = () =>
    func.pipe(Date.now())(
        (d) => d.toString(16),
        func.rearg(string.padLeft)(1, 2, 0)(6*2, "0"),
        codec.hexToBytes
    )




/**
 * Generate 128 bits UUID. Comprised of:
 * - 48 bits of milliseconds since epoch
 * - 32 bits of truncated `sha256` sum of userAgent string
 * - 48 random bits
 *
 * @function genUUID
 * @returns {Uint8Array}
 */
export const genUUID = () => codec.concatBytes(

    // 48 bits (6 bytes): timestamp - milliseconds since epoch
    timestamp(),

    // 32 bits (4 bytes): truncated `sha256` sum of userAgent string
    func.pipe(
        utils.handleException(
            () => utils.isBrowser() ?
                navigator.userAgent :
                "non-browser-env",
            () => "unknown-env"
        )
    )(
        codec.stringToBytes,
        sha256,
        (b) => Array.from(b),
        array.takeEvery(8),
        (a) => Uint8Array.from(a)
    ),

    // 48 random bits (6 bytes)
    random(6)

)




/**
 * Extract `timestamp`, `user agent id` and `random` component
 * from given `uuid`, which was generated using `genUUID()`.
 *
 * @function decodeUUID
 * @param {Uint8Array} uuid
 * @returns {Object}
 */
export const decodeUUID = (uuid) => ({
    timestamp: func.pipe(uuid)(
        array.take(6),
        codec.bytesToHex,
        func.rearg(parseInt)(1, 0)(16),
        (ms) => new Date(ms)
    ),
    uaId: func.pipe(uuid)(
        array.drop(6),
        array.take(4),
        codec.bytesToHex
    ),
    rnd: func.pipe(uuid)(
        array.drop(10),
        array.take(6),
        codec.bytesToHex
    ),
})




/**
 * Generate nonce suitable to use with salsaEncrypt/salsaDecrypt functions.
 *
 * @function salsaNonce
 * @returns {Uint8Array}
 */
export const salsaNonce = () => codec.concatBytes(
    timestamp(),
    random(naclSecretbox.nonceLength - 6)
)




/**
 * Symmetric `xsalsa20-poly1305` encryption.
 * Uses `dchest/tweetnacl-js` implementation.
 *
 * @function salsaEncrypt
 * @see {@link https://bit.ly/tweetnaclsalsa}
 * @param {Uint8Array} key Encryption key.
 * @param {Uint8Array} message A content to encrypt.
 * @returns {Uint8Array} Initialization Vector concatenated with Ciphertext.
 */
export const salsaEncrypt = func.curry((key, message) => (
    (iv) => codec.concatBytes(iv, naclSecretbox(message, iv, key))
)(salsaNonce()))




/**
 * Symmetric `xsalsa20-poly1305` decryption.
 * Uses `dchest/tweetnacl-js` implementation.
 *
 * @function salsaDecrypt
 * @see {@link https://bit.ly/tweetnaclsalsa}
 * @param {Uint8Array} key Decryption key.
 * @param {Uint8Array} ciphertext A content to decrypt.
 * @returns {(Uint8Array|null)} Decrypted message or null.
 */
export const salsaDecrypt = func.curry((key, ciphertext) =>
    naclSecretbox.open(
        array.drop(naclSecretbox.nonceLength)(ciphertext),
        array.take(naclSecretbox.nonceLength)(ciphertext),
        key
    )
)




/**
 * Generate nonce suitable to use with aesEncrypt/aesDecrypt functions.
 *
 * @function aesNonce
 * @returns {Uint8Array}
 */
export const aesNonce = () => random(16)




/**
 * Symmetric `aes256` encryption in counter mode (CTR).
 * Uses `crypto-browserify` implementation.
 *
 * @function aesEncrypt
 * @see {@link https://bit.ly/npmcryptobrowserify}
 * @see {@link https://bit.ly/createcipheriv}
 * @param {Uint8Array} key Encryption key.
 * @param {Uint8Array} message A content to encrypt.
 * @returns {Uint8Array} Initialization Vector concatenated with Ciphertext.
 */
export const aesEncrypt = func.curry((key, message) => {
    let iv = aesNonce(),
        cipher = createCipheriv("aes-256-ctr", key, iv)
    return codec.concatBytes(iv, cipher.update(message), cipher.final())
})




/**
 * Symmetric `aes256` decryption in counter mode (CTR).
 * Uses `crypto-browserify` implementation.
 *
 * @function aesDecrypt
 * @see {@link https://bit.ly/npmcryptobrowserify}
 * @see {@link https://bit.ly/createdecipheriv}
 * @param {Uint8Array} key Decryption key.
 * @param {Uint8Array} ciphertext A content to decrypt.
 * @returns {Uint8Array} Decrypted message.
 */
export const aesDecrypt = func.curry((key, ciphertext) => (
    (decipher) =>
        codec.concatBytes(
            decipher.update(array.drop(16)(ciphertext)),
            decipher.final()
        )
)(createDecipheriv("aes-256-ctr", key, array.take(16)(ciphertext))))




/**
 * Needed constant/headers for encrypt/decrypt functions.
 *
 * @private
 * @constant {Object} encdec
 */
const encdec = Object.freeze({
    MAGIC: "0xDAB0",
    VERSION: "0x0001",
})




/**
 * Double-cipher (`salsa`/`aes`) encryption with `poly1305` MAC.
 * Uses `dchest/tweetnacl-js` "secretbox" for `xsalsa20-poly1305`
 * and `crypto-browserify` for `aes-256-ctr` encryption.
 * Inspired by `keybase.io/triplesec`.
 *
 * Algorithm:
 *
 * 1. `salsaNonce` is created
 * 2. `message` is being encrypted with `xsalsa20`
 *     using first 32 bytes of `key` and `salsaNonce`
 *     producing `[salsaNonce + salsaCiphertext]`
 * 3. `aesNonce` is created
 * 4. `[salsaNonce + salsaCiphertext]` is being encrypted with `aes-256-ctr`
 *     using last 32 bytes of `key` and `aesNonce`
 *     producing `[aesNonce + aesCiphertext]`
 * 5. [`encdec.MAGIC` + `encdec.VERSION` + `aesNonce` + `aesCiphertext`]
 *    is returned as an `Uint8Array` result
 *
 * @function encrypt
 * @see {@link https://bit.ly/toolboxcodec}
 * @see {@link https://bit.ly/toolboxfunc}
 * @param {Uint8Array} key 512 bits (64 bytes) encryption key.
 * @param {Uint8Array} message A content to encrypt.
 * @returns {Uint8Array} [MAGIC] + [VERSION] + [AES IV] + [Ciphertext].
 */
export const encrypt = func.curry((key, message) => {
    if (
        !type.isNumber(key.BYTES_PER_ELEMENT)  ||
        !type.isNumber(message.BYTES_PER_ELEMENT)  ||
        key.BYTES_PER_ELEMENT !== 1  ||
        message.BYTES_PER_ELEMENT !== 1
    ) throw new TypeError("encrypt: Arguments must be of [Uint8Array] type.")

    if (key.length !== 64) throw new RangeError(
        "encrypt: Key must be 512 bits long."
    )

    return codec.concatBytes(
        codec.hexToBytes(encdec.MAGIC),
        codec.hexToBytes(encdec.VERSION),
        func.pipe(message)(
            func.partial(salsaEncrypt)(array.take(32)(key)),
            func.partial(aesEncrypt)(array.drop(32)(key))
        )
    )
})
Object.freeze(Object.assign(encrypt, encdec))




/**
 * Double-cipher (`aes`/`salsa`) decryption with `poly1305` MAC.
 * Uses `dchest/tweetnacl-js` "secretbox" for `xsalsa20-poly1305`
 * and `crypto-browserify` for `aes-256-ctr` decryption.
 * Inspired by `keybase.io/triplesec`.
 *
 * Algorithm:
 *
 * 1. [`encdec.MAGIC` + `encdec.VERSION`] part of `ciphertext` is checked
 * 2. `[salsaNonce + salsaCiphertext]` is being decrypted with `aes-256-ctr`
 *     using last 32 bytes of `key` and `aesNonce`
 *     from `[aesNonce + aesCiphertext]` part of `ciphertext`
 * 3. `message` is being decrypted with `xsalsa20`
 *     using first 32 bytes of `key` and `salsaNonce`
 *     from `[salsaNonce + salsaCiphertext]`
 * 4. If salsa-decryption succeeded then `message` is returned,
 *     otherwise `null`.
 *
 * @function decrypt
 * @see {@link https://bit.ly/toolboxcodec}
 * @see {@link https://bit.ly/toolboxfunc}
 * @param {Uint8Array} key 512 bits (64 bytes) decryption key.
 * @param {Uint8Array} ciphertext A content to decrypt.
 * @returns {Uint8Array|Null} byte representation
 *      of a decrypted content or `null` if decryption is not possible.
 */
export const decrypt = func.curry((key, ciphertext) => {
    if (
        !type.isNumber(key.BYTES_PER_ELEMENT)  ||
        !type.isNumber(ciphertext.BYTES_PER_ELEMENT)  ||
        key.BYTES_PER_ELEMENT !== 1  ||
        ciphertext.BYTES_PER_ELEMENT !== 1
    ) throw new TypeError("decrypt: Arguments must be of [Uint8Array] type.")

    if (key.length !== 64) throw new RangeError(
        "decrypt: Key must be 512 bits long."
    )

    if (!codec.compareBytes(
        codec.concatBytes(
            codec.hexToBytes(encdec.MAGIC),
            codec.hexToBytes(encdec.VERSION)
        ),
        array.take(4)(ciphertext)
    )) throw new Error("decrypt: Magic byte or version mismatch.")

    return func.pipe(array.drop(4)(ciphertext))(
        func.partial(aesDecrypt)(array.drop(32)(key)),
        func.partial(salsaDecrypt)(array.take(32)(key))
    )
})
Object.freeze(Object.assign(decrypt, encdec))




/**
 * Double-cipher scrypt-based key-from-passphrase-deriving encrypter.
 * A `passphrase` is normalized to Normalization Form Canonical Composition.
 * @see {@link http://bit.ly/wikiuniequ}
 *
 * @async
 * @function passphraseEncrypt
 * @param {String} passphrase A password to derive key from.
 * @param {Uint8Array} message A content to encrypt.
 * @param {Object} [opts={}] @see KeyDerivationOptions.
 *      `salt` can be passed here as an additional parameter.
 * @returns {Promise.<String>} base64-encoded ciphertext
 */
export const passphraseEncrypt = async (
    passphrase = string.empty(),
    message = Uint8Array.from([]),
    {
        salt = salt64(),
        count = defKDO.count,
        blockSize = defKDO.blockSize,
        parallelization = defKDO.parallelization,
        derivedKeySize = defKDO.derivedKeySize,
        progressCallback = defKDO.progressCallback,
    } = {}
) =>
    func.pipe(
        salt,
        encrypt(
            await deriveKey(
                func.pipe(passphrase)(
                    (p) => p.normalize("NFC"),
                    codec.stringToBytes
                ),
                salt,
                {
                    count, blockSize, parallelization,
                    derivedKeySize, progressCallback,
                }
            ),
            message
        )
    )(
        codec.concatBytes,
        codec.b64enc
    )




/**
 * Double-cipher scrypt-based key-from-passphrase-deriving decrypter.
 * A `passphrase` is normalized to Normalization Form Canonical Composition.
 * @see {@link http://bit.ly/wikiuniequ}
 *
 * @async
 * @function passphraseDecrypt
 * @param {String} passphrase A password to derive key from.
 * @param {String} ciphertext A base64-encoded content to decrypt.
 * @param {KeyDerivationOptions} [opts={}] @see KeyDerivationOptions.
 * @returns {Promise.<Uint8Array>|Promise.<Null>} byte representation
 *      of a decrypted content or `null` if decryption is not possible.
 */
export const passphraseDecrypt = (
    passphrase = string.empty(),
    ciphertext = string.empty(),
    {
        count = defKDO.count,
        blockSize = defKDO.blockSize,
        parallelization = defKDO.parallelization,
        derivedKeySize = defKDO.derivedKeySize,
        progressCallback = defKDO.progressCallback,
    } = {}
) => (
    async (cipherBytes) =>
        decrypt(
            await deriveKey(
                func.pipe(passphrase)(
                    (p) => p.normalize("NFC"),
                    codec.stringToBytes
                ),
                array.take(64)(cipherBytes),
                {
                    count, blockSize, parallelization,
                    derivedKeySize, progressCallback,
                }
            ),
            array.drop(64)(cipherBytes)
        )
)(codec.b64dec(ciphertext))




/**
 * Library version.
 *
 * @constant {String} version
 */
export { version } from "../package.json"