Search code examples
node.jscryptographyaes

Question about AES encryption library "crypto-js" in Node.JS


Here is my sample code

const CryptoJS = require('crypto-js')

const plaintext = "hello world"
const passphrase = "my_passphrase"

const encrypted = CryptoJS.AES.encrypt(plaintext, passphrase)
console.log("plaintext =", plaintext)
console.log("passphrase =", passphrase)
console.log("-----------------------------------------------------------")
console.log("key =", encrypted.key+'')
console.log("iv =", encrypted.iv+'')
console.log("salt =", encrypted.salt+'')
console.log("encrypted =", encrypted+'')

console.log("-----------------------------------------------------------")

var decrypted = CryptoJS.AES.decrypt(encrypted.toString(), passphrase);
console.log("decrypted =", decrypted.toString(CryptoJS.enc.Utf8))

And the output goes:

plaintext = hello world
passphrase = my_passphrase
-----------------------------------------------------------
key = 7bb8ed7c0c9ad5b714a57073068f441dfbf032173e60bf61deea2f9a5ea2ad3a
iv = 72b8e7e60fbcf1328fd1994ea2cc7f06
salt = 03a04d2d438b4cac
encrypted = U2FsdGVkX18DoE0tQ4tMrKBbK/veZm1k0vGmFxl6sow=
-----------------------------------------------------------
decrypted = hello world

As I know and from the output, you can see crypto-js derives the actual key with random salt and random IV value from passphrase automatically in encryption process internally, and then use the derived key to encrypt the plaintext.

My question is that since the salt and IV are random generated, then why decryption function can derive the same AES Key with ONLY key passphrase? Could it be salt and IV embedded in encrypted data? If so, use salt or not does not matters, right?


Solution

  • As I know and from the output, you can see crypto-js derives the actual key with random salt and random IV value from passphrase automatically in encryption process internally, and then use the derived key to encrypt the plaintext.

    This is only partly correct. CryptoJS does indeed use a key derivation function when the key material is passed as string. In this case, however, only a random 8 bytes salt is generated during encryption and not an IV. The IV is derived along with the key using a key derivation function and with the password and salt as input according to the algorithm used (AES-256 by default). Encryption is then performed applying the derived key and IV (with CBC mode and PKCS#7 padding by default).
    Note that a random salt is important for security because it generates different key/IV pairs for each encryption. The reuse of key/IV pairs would mean a more or less serious vulnerability depending on the mode.

    My question is that since the salt and IV are random generated, then why decryption function can derive the same AES Key with ONLY key passphrase?

    It cannot. When using a key derivation function, the salt must be passed to the decrypting side in some form (but not the IV, as it is derived). The salt and password are then used to reconstruct the key and IV during decryption using the key derivation function. Finally, the decryption is carried out with the key and IV derived in this way.

    Could it be salt and IV embedded in encrypted data?

    The salt can be passed to CryptoJS.AES.decrypt() in different ways (as mentioned, the IV is not passed as it is derived), for instance encapsulated in a CipherParams object (wrapping key, iv, salt and ciphertext).
    A CipherParams is also generated by CryptoJS.AES.encrypt(), so the return value can be passed directly to CryptoJS.AES.decrypt(). However, it is sufficient for decryption if the CipherParams object only contains salt and ciphertext.
    Alternatively, the ciphertext and salt can be extracted from the CipherParams object and passed to the decrypting side in any desired format.
    A special format is the Base64 encoded OpenSSL format, which concatenates the ASCII encoding of Salted__, the salt and the ciphertext in this order. Characteristic for the Base64 encoded OpenSSL format is that it always starts with U2FsdGVkX1 because of the constant prefix. CryptoJS supports this format directly with the toString() function of the CipherParams object.

    The following script demonstrates this:

    var password = "test passphrase"
    var plaintext = "The quick brown fox jumps over the lazy dog"
    
    // test 1: pass CipherParams object directly from encrypt() to decrypt()
    var dataCP = CryptoJS.AES.encrypt(plaintext, password)
    console.log("test 1:", CryptoJS.AES.decrypt(dataCP, password).toString(CryptoJS.enc.Utf8))
    
    // test 2: pass a fresh CipherParams object to decrypt() that contains only salt and ciphertext
    var dataCP_onlySaltAndCiphertext = CryptoJS.lib.CipherParams.create({ salt: dataCP.salt, ciphertext: dataCP.ciphertext })
    console.log("test 2:", CryptoJS.AES.decrypt(dataCP_onlySaltAndCiphertext, password).toString(CryptoJS.enc.Utf8))
    console.log("       ", CryptoJS.AES.decrypt({ salt: dataCP.salt, ciphertext: dataCP.ciphertext }, password).toString(CryptoJS.enc.Utf8)) // or more compact
    
    // test 3: pass data to decrypt() in Base64 encoded OpenSSL format using toString()
    var dataOpenSSL = dataCP.toString()
    console.log("test 3:", CryptoJS.AES.decrypt(dataOpenSSL, password).toString(CryptoJS.enc.Utf8))
    
    // test 4: pass data to decrypt() in explicitly generated Base64 encoded OpenSSL format 
    var dataOpenSSL_explicit = CryptoJS.enc.Utf8.parse("Salted__").concat(dataCP.salt).concat(dataCP.ciphertext).toString(CryptoJS.enc.Base64)
    console.log("test 4:", CryptoJS.AES.decrypt(dataOpenSSL_explicit, password).toString(CryptoJS.enc.Utf8))
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>

    If so, use salt or not does not matters, right?

    This question is not quite clear to me. If you mean that CryptoJS handles the salt implicitly, then yes (e.g. in OpenSSL format). If you are asking whether the salt can be disabled during key derivation: No, this is not possible with CryptoJS (unlike OpenSSL). Of course, it is also not recommended to disable the salt for security reasons.


    Details on the key derivation function:

    CryptoJS applies the OpenSSL proprietary key derivation function EVP_BytesToKey() with an iteration count of 1 and MD5 as digest. In conjunction with the OpenSSL format with regard to the encrypted data, this achieves compatibility with OpenSSL (as long as MD5 is used as digest for OpenSSL).
    MD5 was the default digest in older OpenSSL versions. From v1.1.0, however, OpenSSL switched to SHA-256 as default digest. Hence, CryptoJS is only compatible with newer OpenSSL versions if the digest is explicitly specified as MD5 in the OpenSSL statement using -md.
    Attention: EVP_BytesToKey() is considered insecure nowadays, especially because of the iteration count of 1 and the use of the broken digest MD5. Instead, for new implementations at least PBKDF2 should be applied (supported by both CryptoJS and OpenSSL) or, if available, Argon2.

    Note that if the key material is passed as WordArray, no key derivation is performed, but the key material is used directly as key, s. here. Then, an IV must be explicitly specified as WordArray (for all modes that use one).

    The following script performs the built-in key derivation explicitly and shows that both results are equivalent:

    // 1. Encrypt with built-in key derivation
    var password = "test passphrase"
    var plaintext = "The quick brown fox jumps over the lazy dog"
    var encryptedCP = CryptoJS.AES.encrypt(plaintext, password) // keymaterial is a string => encryption with key derivation
    var saltWA = encryptedCP.salt
    console.log("salt", saltWA.toString())
    console.log("ciphertext, OpenSSL format, built-in", encryptedCP.toString())
    
    // 2. Encrypt with explicit key derivation
    // - Generate 32 bytes key key and 16 bytes IV using EVP_BytesToKey using password and salt from above
    var keySize = 8; // key size for AES-256: 8 words (a 4 bytes) = 32 bytes
    var ivSize = 4;  // iv size for AES: 4 words (a 4 bytes) = 16 bytes
    var keyIvWA = CryptoJS.EvpKDF(password, saltWA, {keySize: keySize + ivSize, iterations: 1, hasher: CryptoJS.algo.MD5})
    var keyWA = CryptoJS.lib.WordArray.create(keyIvWA.words.slice(0, keySize), keySize * 4)
    var ivWA = CryptoJS.lib.WordArray.create(keyIvWA.words.slice(keySize), ivSize * 4)
    // - Encrypt with AES-256 in CBC mode (default) and PKCS#7 padding (default) without built-in key derivation
    var encryptedCP = CryptoJS.AES.encrypt(plaintext, keyWA, {iv: ivWA}) // keymaterial is a WordArray => encryption without key derivation
    var encryptedCPOpenSSL = CryptoJS.enc.Utf8.parse("Salted__").concat(saltWA).concat(encryptedCP.ciphertext).toString(CryptoJS.enc.Base64)
    console.log("ciphertext, OpenSSL format, explicit", encryptedCPOpenSSL)
    
    // 3. Decrypt
    var decrypted = CryptoJS.AES.decrypt(encryptedCPOpenSSL, password)
    console.log(decrypted.toString(CryptoJS.enc.Utf8))
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"></script>


    Be aware that the development of CryptoJS was recently discontinued and the library is no longer maintained. Recommended alternatives are WebCrypto or the crypto module of NodeJS.