Search code examples
aesphp-openssl

AES256-CBC Ciphertext from BouncyCastle decrypts in BC, but PHP openssl_decrypt fails w/same inputs


I encrypted a String with 256-bit AES (CBC mode) using BouncyCastle (technically, SpongyCastle) under Android and logged the base64-encoded values of the ciphertext, key, and iv.

I then wrote a test program (as an instrumented Android Unit Test) that hardcodes all three values as base64-encoded Strings. It decrypts successfully when I run the code below under Android.

The exact same base64-encoded ciphertext, key, and iv fail under PHP (5.6.30). I'm struggling to understand why. In both cases, the ciphertext, key, and iv all start out as hardcoded base64-encoded Strings with the exact same values.

Among other things, it's complaining that the key length (32 bytes) is invalid (the last time I checked, 32 * 8 == 256).

Android (SpongyCastle) code:

@Test
    public void crossplatformTest() {
        String ciphertext64 = "gfcC6t1BarndpzMuvYj2JFpWHqlWSJMhTtxPN7QjyEg=";
        String key64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
        String iv64="AAECAwQFBgcICQoLDA0ODw==";

        byte[] ciphertext = Base64.decode(ciphertext64, Base64.DEFAULT);
        byte[] key = Base64.decode(key64, Base64.DEFAULT);
        byte[] iv = Base64.decode(iv64, Base64.DEFAULT);

        byte[] decrypted = Crypto.decryptWithAesCBC(ciphertext, key, iv);
        // the following assertion succeeds.
        assertEquals("Ugh! Endless grief!", new String(decrypted, StandardCharsets.UTF_8));
    }

PHP (OpenSSL) code:

<?php
    $ciphertext64 = "gfcC6t1BarndpzMuvYj2JFpWHqlWSJMhTtxPN7QjyEg=";
    $key64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
    $iv64="AAECAwQFBgcICQoLDA0ODw=="; 

    // UPDATE: THE LINE BELOW IS THE PROBLEM. SEE ANSWER FOR SOLUTION.
    $decrypted = openssl_decrypt($ciphertext64, 'aes-256-cbc', $key64, 0, $iv64);
    if ($decrypted == false) {
        while ($msg = openssl_error_string())
            echo "<p>$msg</p>\n";
        echo("key length=" . strlen(base64_decode($key64, true))."<BR>\n");
        echo("iv length=" . strlen(base64_decode($iv64, true))."<BR>\n");
    }

PHP's output:

error:0607A082:digital envelope routines:EVP_CIPHER_CTX_set_key_length:invalid key length
error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt

key length=32
iv length=16

According to what little documentation for openssl_decrypt seems to exist at php.net (http://php.net/manual/en/function.openssl-decrypt.php), openssl_decrypt wants base64-encoded strings for ciphertext, key, and iv.

For what it's worth, here's the code I used to encrypt the original String, and the code I'm using on Android to decrypt it:

public static byte[] decryptWithAesCBC(byte[] ciphertext, byte[] key, byte[] iv) {
        try {
            PaddedBufferedBlockCipher aes = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));
            CipherParameters ivAndKey = new ParametersWithIV(new KeyParameter(key), iv);
            aes.init(false, ivAndKey);
            return cipherData(aes, ciphertext);
        }
        catch (InvalidCipherTextException e) {
            throw new RuntimeException(e);
        }
    }

private static byte[] cipherData(PaddedBufferedBlockCipher cipher, byte[] data) throws InvalidCipherTextException {
        int minSize = cipher.getOutputSize(data.length);
        byte[] outBuf = new byte[minSize];
        int length1 = cipher.processBytes(data, 0, data.length, outBuf, 0);
        int length2 = cipher.doFinal(outBuf, length1);
        int actualLength = length1 + length2;
        byte[] ciphertext = new byte[actualLength];
        for (int x=0; x < actualLength; x++) {
            ciphertext[x] = outBuf[x];
        }
        return ciphertext;
    }

public static byte[] encryptWithAesCBC(byte[] plaintext, byte[] key, byte[] iv) {
        try {
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding", "SC");

            PaddedBufferedBlockCipher aes = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));
            CipherParameters ivAndKey = new ParametersWithIV(new KeyParameter(key), iv);
            aes.init(true, ivAndKey);
            return cipherData(aes, plaintext);
        }
        catch (NoSuchAlgorithmException | NoSuchProviderException | NoSuchPaddingException | InvalidCipherTextException e) {
            throw new RuntimeException(e);
        }
    }

Solution

  • OK, found the definitive answer & confirmed that it works.

    The official documentation for openssl_decrypt at php.net is bad, and the first few comments taken in isolation are somewhere between "incomplete" and "misleading".

    According to the most authoritative documentation for openssl_decrypt (ext/openssl/openssl.c in the source code for PHP 5.6.30 itself):

    $data is presumed to be base64-encoded unless OPENSSL_RAW_DATA is explicitly specified as an option. The source code itself makes this clear:

    if (!(options & OPENSSL_RAW_DATA)) {
        base64_str = (char*)php_base64_decode((unsigned char*)data, data_len, &base64_str_len);
        if (!base64_str) {
            php_error_docref(NULL TSRMLS_CC, E_WARNING, "Failed to base64 decode the input");
            RETURN_FALSE;
        }
        data_len = base64_str_len;
        data = base64_str;
    }
    

    HOWEVER, the values of $password and $iv are cast DIRECTLY to (unsigned char *).

    Thus, the correct usage is:

    $ciphertext64 = "gfcC6t1BarndpzMuvYj2JFpWHqlWSJMhTtxPN7QjyEg=";
    $key64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=";
    $iv64="AAECAwQFBgcICQoLDA0ODw==";
    
    $key = base64_decode($key64, true);
    $iv = base64_decode($iv64, true);
    
    $decrypted = openssl_decrypt($ciphertext64, 'aes-256-cbc', $key, 0, $iv);
    

    ... which outputs the value of the String I originally encrypted using BouncyCastle (SpongyCastle) on Android -- "Ugh! Endless grief!"

    As to why the official documentation is so bad, PHP's developers have apparently decided to quietly deprecate the openssl functions, with the expectation that once PHP 7.2 becomes mainstream, everyone will use libsodium going forward. The addition of libsodium to default installations of PHP 7.2 onward is awesome, but unfortunately, it won't do anybody with a shared web hosting account (who lacks admin, let alone root, shell access to the server and can't install their own extensions) much good until at least early 2018.

    In other words, if you found this post via Google sometime after late 2017 while attempting to solve some problem you're having with openssl and PHP, check to see whether your web server has PHP 7.2 available.

    • If it doesn't, and neither upgrading to PHP 7.2+ nor getting libsodium installed on the server is a viable option, proceed with my solution.

    • If it does, forget that the openssl_* functions ever existed, and go straight to libsodium (which by design makes it a lot harder to accidentally screw up)