简体   繁体   中英

Issue encrypting with PHP (openssl_encrypt), then decrypting with JS (CryptoJS)

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!' */

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.

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM