5

My first time using CryptoJS, and I'm struggling to decrypt a string I encrypted using openssl_encrypt() in PHP.

PHP 5.6.13.0 and CryptoJS 3.1.2


First, my PHP:

$encryptHash = hash_pbkdf2("sha256", "0000", "secret", 1000, 32);
var_dump($encryptHash);

$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length('aes-256-cbc'));
var_dump(bin2hex($iv));

$encrypted = openssl_encrypt("hello! this is my string!", 'aes-256-cbc', $encryptHash, 0, $iv);
var_dump($encrypted);

$encrypted = base64_encode($encrypted.":".bin2hex($iv));
echo "\r\n".$encrypted;

This gives me the following output:

string(32) "59b6ab46d379b89d794c87b74a511fbd"
string(32) "0aaff094b6dc29742cc98a4bac8bc8f9"
string(44) "xHIxg1HDUOqyhBmAaU2Sx3ct8GaKaeE5w4d1KM1yuDw="

eEhJeGcxSERVT3F5aEJtQWFVMlN4M2N0OEdhS2FlRTV3NGQxS00xeXVEdz06MGFhZmYwOTRiNmRjMjk3NDJjYzk4YTRiYWM4YmM4Zjk=

Now my JS:

var encryptedString = "eEhJeGcxSERVT3F5aEJtQWFVMlN4M2N0OEdhS2FlRTV3NGQxS00xeXVEdz06MGFhZmYwOTRiNmRjMjk3NDJjYzk4YTRiYWM4YmM4Zjk=";

var key256Bits  = CryptoJS.PBKDF2("0000", "secret", { keySize: 128/32, iterations: 1000, hasher: CryptoJS.algo.SHA256 });
var keyAsHex = key256Bits.toString(CryptoJS.enc.Hex);

/* keyAsHex = "59b6ab46d379b89d794c87b74a511fbd" */

var rawData = atob(encryptedString);
var rawPieces = rawData.split(":");

var crypttext = rawPieces[0];
var iv = rawPieces[1];

/* crypttext = "xHIxg1HDUOqyhBmAaU2Sx3ct8GaKaeE5w4d1KM1yuDw=" */
/* iv = "0aaff094b6dc29742cc98a4bac8bc8f9" */

/* So far so good? */

var plaintextArray = CryptoJS.AES.decrypt(
  { ciphertext: CryptoJS.enc.Base64.parse(crypttext) },
  CryptoJS.enc.Hex.parse(keyAsHex),
  { iv: CryptoJS.enc.Hex.parse(iv) }
);

/* plaintextArray: d.WordArray.n.extend.init
    sigBytes: -67
    words: Array[8]
        0: 1419734786
        1: -2048883413
        2: -1709437124
        3: 736946566
        4: 718053567
        5: -64039355
        6: 1868905697
        7: -910423965 */

var output = CryptoJS.enc.Utf8.stringify(plaintextArray);

/* output = "" */

As you can see, my output is an empty string. Anyone attempted to do something similar? I'm stumped!

Edit

Turns out my key lengths were incorrect! Here's my working PHP (encrypt) and JS (decrypt) code:


PHP:

$encryptHash = hash_pbkdf2("sha256", "0000", "secret", 1000, 32, true);
var_dump($encryptHash);

$iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length("aes-256-cbc"));
var_dump($iv);

$encrypted = openssl_encrypt("hello! this is a test!", "aes-256-cbc", $encryptHash, 0, $iv);
var_dump($encrypted);

$encrypted = base64_encode($encrypted.":".bin2hex($iv));
echo "\r\n".$encrypted;

Gives me the following:

string(32) "Y½FËy©ØyLçÀJQ▼¢▄▄êI╩öo§(NtÙת‼ç"
string(16) "àX§ $VÇ‼♣┘█²áÓßt"
string(44) "VIzzao8Wdo8HPM015v6c5Q77ervGUIVbL6ERKRXb0fU="

Vkl6emFvOFdkbzhIUE0wMTV2NmM1UTc3ZXJ2R1VJVmJMNkVSS1JYYjBmVT06ODU1ODE1MjAyNDU2ODAxMzA1ZDlkYmZkYTBlMGUxNzQ=

JS:

var encryptedString = "Vkl6emFvOFdkbzhIUE0wMTV2NmM1UTc3ZXJ2R1VJVmJMNkVSS1JYYjBmVT06ODU1ODE1MjAyNDU2ODAxMzA1ZDlkYmZkYTBlMGUxNzQ=";

var key256Bits  = CryptoJS.PBKDF2("0000", "secret", { keySize: 256/32, iterations: 1000, hasher: CryptoJS.algo.SHA256 });

var rawData = atob(encryptedString);
var rawPieces = rawData.split(":");

var crypttext = rawPieces[0];
var iv = CryptoJS.enc.Hex.parse(rawPieces[1]);

var cipherParams = CryptoJS.lib.CipherParams.create({ciphertext: CryptoJS.enc.Base64.parse(crypttext)});

var plaintextArray = CryptoJS.AES.decrypt(
  cipherParams,
  key256Bits,
  { iv: iv }
);

var output = CryptoJS.enc.Utf8.stringify(plaintextArray);

/* output === 'hello! this is a test!' */
Mike
  • 105
  • 3
  • 8
  • Converting `plaintextarray` to hex will probably be far more helpful to us that an array of 4-byte words displayed as signed integers. – Sammitch Sep 10 '15 at 17:10
  • Calling `.toString(CryptoJS.enc.Hex)` on `plaintextArray` also returns an empty string – Mike Sep 10 '15 at 23:12
  • 1
    Is it possible to do this the other way round? (I.E JS(encrypt) PHP(decrypt))? I am having issues replicating `openssl_random_pseudo_bytes(openssl_cipher_iv_length("aes-256-cbc"));` – maxisme Mar 09 '16 at 21:40

2 Answers2

4

TL;DR - Try using a 32 byte key rather than a 16 byte key.

After composing an earlier answer and ultimately deleting it, having disproven my own theory about this being a problem with padding :-), I am now fairly certain that the problem might just be to do with key lengths.

Whilst trying to reproduce your issue I couldn't to get the first block of ciphertext to be identical when generated using openssl_encrypt vs CryptoJS. Then I doubled the length of the key and it worked.

The key you're generating above is 32 characters, but only 16 bytes once converted, so try doubling that and see what happens.

FWIW, here is the PHP code I used to test key lengths:

$data = "hello! this is a test!";
$method = 'aes-256-cbc';
$key = '59b6ab46d379b89d794c87b74a511fbd59b6ab46d379b89d794c87b74a511fbd';
$iv = '0aaff094b6dc29742cc98a4bac8bc8f9';

$e = openssl_encrypt( $data, $method, hex2bin( $key ), 0, hex2bin( $iv ));

echo 'Ciphertext: [', bin2hex( base64_decode( $e )), "]\n";
echo 'Key:        [', $key, "]\n";
echo 'Cleartext:  [', openssl_decrypt( $e, $method, hex2bin( $key ), 0, hex2bin( $iv )), "]\n";

// Test with openssl on the command line as well, just to be sure!
file_put_contents( 'clear.txt', $data );

$exec = "openssl enc -$method -e -in clear.txt -out encrypted.txt -base64 -nosalt -K $key -iv $iv";
exec ($exec);
$out = file_get_contents( 'encrypted.txt' );
echo 'Ciphertext: [', bin2hex( base64_decode(trim($out))), "]\n";

And here is the compatible JavaScript, which I run using jsc on my Mac:

var data = "hello! this is a test!";
var key = '59b6ab46d379b89d794c87b74a511fbd59b6ab46d379b89d794c87b74a511fbd';
var iv = '0aaff094b6dc29742cc98a4bac8bc8f9';

var encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(data), CryptoJS.enc.Hex.parse(key), { iv: CryptoJS.enc.Hex.parse(iv) });

print( 'Ciphertext: [' + encrypted.ciphertext + ']' );
print( 'Key:        [' + encrypted.key + ']' );

cipherParams = CryptoJS.lib.CipherParams.create({ciphertext: CryptoJS.enc.Hex.parse(encrypted.ciphertext.toString())});
var decrypted = CryptoJS.AES.decrypt(cipherParams, CryptoJS.enc.Hex.parse(key), { iv: CryptoJS.enc.Hex.parse(iv) });

print( 'Cleartext:  [' + decrypted.toString(CryptoJS.enc.Utf8) + ']');

These two chunks of code produce identical ciphertext regardless of the length of the input, which confirms that the padding strategy is compatible between the two libraries. However, if you halve the length of the keys, the ciphertext will no longer be identical, which obviously means decryption isn't going to be compatible either.

UPDATE

I just discovered that hash_pbkdf2() returns ASCII hex strings by default, so you should either convert $encryptHash to binary with hex2bin() before passing it to openssl_encrypt() or set the last parameter of hash_pbkdf2() to true to get raw output.

UPDATE 2

I have just confirmed that your code will work if you make the following changes:

In the PHP, change the key size from 32 to 64 bytes and add the raw output option when generating the key:

$encryptHash = hash_pbkdf2("sha256", "0000", "secret", 1000, 64, 1);

Change the key length from 128 to 256 bits in the JavaScript:

var key256Bits  = CryptoJS.PBKDF2("0000", "secret", { keySize: 256/32, iterations: 1000, hasher: CryptoJS.algo.SHA256 });

Hopefully those changes work when you try them.

JamesG
  • 4,288
  • 2
  • 31
  • 36
  • Ahh that's it! Thank you @JamesG - really appreciate it. I'll update my original question with the working code shortly. – Mike Sep 11 '15 at 10:10
  • How to do this without a hex key like utf8? – Aylian Craspa Jul 08 '18 at 06:20
  • They're not really "hex keys" - the keys themselves are 32 random bytes but we use hexadecimal strings to represent them because that's the easiest way to notate them in code. Whilst you could use a UTF8 string as a key, I wouldn't recommend it, because UTF8 characters vary in the number of bytes used to represent each character, so it might be challenging to ensure you have a UTF8 string that is exactly 32 bytes in length. Also, the underlying encoding scheme of UTF8 would not lend itself to high levels of entropy, which is a property you want in an encryption key. – JamesG Jul 09 '18 at 07:22
0

The idea about the wrong key length and the bool $binary = true in hash_pbkdf2 solved all my issues as well, as it's not so obvious once one starts to delve into matter. I'm adding my solution with some additional explanations so one can save some time while looking for all these pieces of information.

Another quite important detail I found out, which brings even more confusion when encrypting in PHP and decrypting in JS, is the 0 options' parameter of the openssl_encrypt function. This one has a huge impact on the returned format of the data, which one should be aware of using CryptoJS.

With that 4th options' parameter set to 0, the returned data is encoded by openssl_encrypt as Base64, so one would need to decode it twice from Base64 in CryptoJS. However, with that option set to OPENSSL_RAW_DATA the data is not implicitly encoded to Base64 in PHP.

PHP 7.4:

    $plainTextToEncrypt = "Lorem ipsum";
    $passphrase = "obligate properly elective edge"; // from: https://www.useapassphrase.com
    $iterations = 1001;
    $salt = random_bytes(32);
    // It doesn't really matter how many characters the `salt` is.
    $hexSalt = bin2hex($salt);
    var_dump($hexSalt); 
    // string (64): 0c5597db78ac4aedf2bdfb1d4ce7935c270876284239b0ef48ba63d08ed164b5
    
    $key = hash_pbkdf2("sha256", $passphrase, $hexSalt, $iterations, 32, true);
    var_dump($key);
    // in raw as string (32) it then looks like this: �g��)���2�'�����M2eCY�I�J��^

    // More readable 64 lowercase hexits long key:
    $keyToHex = bin2hex($key);
    var_dump($keyToHex);
    // string (64): ce670c8a7f8329b78306d9329927faa4f880c34d32654359e949a74aa77fc45e

    /*
       By the way, $keyToHex is the same as the following 32 characters long
       raw binary $key if generated as 64 lowercase hexits long key (as it's discussed
       in the previous post in the 'UPDATE' section):
    */
    $keyIn64Hexits = hash_pbkdf2("sha256", $passphrase, $hexSalt, $iterations, 64, false);
    var_dump($keyIn64Hexits);
    // string (64):  ce670c8a7f8329b78306d9329927faa4f880c34d32654359e949a74aa77fc45e

    /*
        DON'T confuse yourself here! Below I encrypt with the raw binary $key, which is
        32 characters long, and with the `OPENSSL_RAW_DATA` option in `openssl_encrypt()`.
    */

    $cipher = 'aes-256-ctr';
    if (in_array($cipher, openssl_get_cipher_methods()))
    {
        $ivLen = openssl_cipher_iv_length($cipher);
        var_dump($ivLen);
        // int (16) <--- should be of the appropriate length used in the encryption algorithm of your choice!
        $iv = random_bytes($ivLen);
        var_dump($iv);
        // string (16): 7��������a��
        $ivInHex = bin2hex($iv); // <--- In CryptoJS I'm working with the Hex variant once it's decoded from Base64 (see the 2nd code block with my JS)
        var_dump($ivInHex);
        // string (32): 3706f4f089c2f6f2e0aafa6191170dae
        $iv64 = base64_encode($ivInHex);
        // $iv64 in Base64 looks like this: MzcwNmY0ZjA4OWMyZjZmMmUwYWFmYTYxOTExNzBkYWU=

        $encryptedData = openssl_encrypt($plainTextToEncrypt, $cipher, $key, OPENSSL_RAW_DATA, $ivInHex);
        // with `OPENSSL_RAW_DATA` you'll get the raw binary data: ùg3UDCY��
        // bin2hex($encryptedData) looks like: c3b967335544430759b2c1
        // base64_encode($encryptedData) looks like: w7lnM1VEQwdZssE= <--- btw, this is how one-time encoding to Base64 looks like

        // Let's prepare it for transport
        $data = array("ciphertext" => base64_encode($encryptedData), "iv" => $iv64, "salt" => $hexSalt);
        // Whatever you're doing with the encrypted data later, e.g.:
        // return json_encode($data);
    }

CryptoJS 4.0.0

    const encryptedPlainText = "w7lnM1VEQwdZssE=";
    const passphrase = "obligate properly elective edge"; // don't save it here, get it from some other place
    const salt = "0c5597db78ac4aedf2bdfb1d4ce7935c270876284239b0ef48ba63d08ed164b5"; // 64 characters
    const iv = "MzcwNmY0ZjA4OWMyZjZmMmUwYWFmYTYxOTExNzBkYWU=";
    const parsedSalt = CryptoJS.enc.Hex.parse(salt); // or: CryptoJS.enc.Latin1.parse(salt);
    const parsedIV = CryptoJS.enc.Base64.parse(iv);

    const key = CryptoJS.PBKDF2(passphrase, parsedSalt, {
        hasher: CryptoJS.algo.SHA256,
        keySize: 256 / 32, // the length of the key is then 32 characters
        iterations: 1001,
    });
    // you can check the length in bytes like so:
    console.log("KEY (in bytes in Latin1):",
        CryptoJS.enc.Latin1.parse(CryptoJS.enc.Latin1.stringify(key))
      );
    // KEY (in bytes in Latin1):  t.init {words: Array(8), sigBytes: 32}

    console.log("KEY (toString in Latin1): ", key.toString(CryptoJS.enc.Latin1));
    // KEY (toString in Latin1):  ÎgŠƒ)·ƒÙ2™'ú¤ø€ÃM2eCYéI§J§Ä^

    // However in UTF-8 is's 64 characters, so keep this in mind:
    console.log("KEY (Utf8): ", key.toString());
    // ce670c8a7f8329b78306d9329927faa4f880c34d32654359e949a74aa77fc45e
    
    const decrypted = CryptoJS.AES.decrypt(
        {
          ciphertext: CryptoJS.enc.Base64.parse(encryptedPlainText),
        },
        key,
        {
          keySize: 32, // optional here, as it was set in CryptoJS.PBKDF2() above
          iv: parsedIV,
          mode: CryptoJS.mode.CTR,
          padding: CryptoJS.pad.NoPadding,
          /*
              Depending on the contents of the data you're encrypting (trailing spaces or alike),
              the padding can also be set to 'NoPadding' to avoid the additional
              characters or blocks of padding.

              See this post for explanation:
              https://stackoverflow.com/questions/48673427/cryptojs-with-hex-key-not-decrypting-properly
              I use `NoPadding`, since encryptedPlainText is already encoded into Base64.
              Look this post for more details on this topic:
              https://stackoverflow.com/questions/61717485/incorrect-decrypted-string-implemented-using-aes-ecb-nopadding-and-base-64-with/61737626
          */
        }
      );

    console.log("DECRYPTED TEXT:", decrypted);
    // DECRYPTED TEXT: t.init {words: Array(4), sigBytes: 11}
    // It corresponds to 1 byte per character as in `Latin1` encoding.
    // The `Lorem ipsum` text decrypted below is 11 bytes long in Latin1.
    // See: https://stackoverflow.com/questions/2708958/differences-between-utf8-and-latin1

    console.log("DECRYPTED (in UTF8):", CryptoJS.enc.Utf8.parse(decrypted));
    // DECRYPTED (UTF8): t.init {words: Array(6), sigBytes: 22}
    // It corresponds to the UTF8's 2 bytes per character.

    console.log("DECRYPTED (toString in Latin1):", decrypted.toString(CryptoJS.enc.Latin1));
    // DECRYPTED (toString in Latin1): Lorem ipsum

Dmitry G.
  • 61
  • 1
  • 4