Search code examples
encryptionaesphp-opensslwebcrypto-apisubtlecrypto

Decrypt AES-256-CBC using openssl_decrypt in PHP from Subtle Crypto Javascript Payload


I'm trying to encrypt something in JS using webcrypto/window.crypto to AES-256-CBC and trying to decrypt it using PHP's openssl_decrypt function.

My problem is that the decyrption function simply returns false and thus does not seem to work.

const encoder = new TextEncoder();
const encoded = encoder.encode('Hello this is a test.');

const encryptionKey = await window.crypto.subtle.generateKey(
    {
        name: 'AES-CBC',
        length: 256,
    },
    true,
    ['encrypt', 'decrypt'],
);

const iv = window.crypto.getRandomValues(new Uint8Array(16));
const cipher = await window.crypto.subtle.encrypt(
    {
        name: 'AES-CBC',
        iv,
    },
    encryptionKey,
    encoded,
);


const exportedKey = await window.crypto.subtle.exportKey(
    'jwk',
    encryptionKey,
);

console.log(exportedKey.k); 

sendToBackend({
    cipher: btoa(new Uint8Array(cipher)), // "MTMsMjIzLDE5NSwxNzYsMjA0LDE5MSwxOTYsMjEyLDIwNCwyMzAsMjcsMSwxMjAsMTQzLDE2MSwxMTgsMTYwLDIzOSw4NywyMDksMjQ0LDIwNCwyMzgsODYsMTgzLDIyOCwxMzksMjIwLDcwLDY5LDI0OSwxODQ="
    iv: btoa(new Uint8Array(iv)), // "MTQ5LDE2Nyw4LDE2NywyMjAsMTA4LDEwMSw1Niw4Miw3MiwxMjAsMjM5LDE4NCw0OCwyNTIsMTE=",
    password: exportedKey.k, // "szq1aOg-F_72vWrdJatWyQp3iOXIus-cE19sO4bSOLs"
});

Now when I try to decrypt this in the backend using PHP, I get false:

$key = "szq1aOg-F_72vWrdJatWyQp3iOXIus-cE19sO4bSOLs";
$payload = "MTMsMjIzLDE5NSwxNzYsMjA0LDE5MSwxOTYsMjEyLDIwNCwyMzAsMjcsMSwxMjAsMTQzLDE2MSwxMTgsMTYwLDIzOSw4NywyMDksMjQ0LDIwNCwyMzgsODYsMTgzLDIyOCwxMzksMjIwLDcwLDY5LDI0OSwxODQ=";
$iv = "MTQ5LDE2Nyw4LDE2NywyMjAsMTA4LDEwMSw1Niw4Miw3MiwxMjAsMjM5LDE4NCw0OCwyNTIsMTE=";
$dec = openssl_decrypt($payload, 'AES-256-GCM', $key, false, $iv);
var_dump($dec); // false

Is there something I am missing?


Solution

  • On the JavaScript side, the Base64 encoding fails, as can be seen from the length of the result. In the following JavaScript code the function ab2b64() is used for this conversion:

    (async () => {
    
        const encoder = new TextEncoder();
        const encoded = encoder.encode('Hello this is a test.');
    
        const encryptionKey = await window.crypto.subtle.generateKey(
            {
                name: 'AES-CBC',
                length: 256,
            },
            true,
            ['encrypt', 'decrypt'],
        );
    
        const iv = window.crypto.getRandomValues(new Uint8Array(16));
        const cipher = await window.crypto.subtle.encrypt(
            {
                name: 'AES-CBC',
                iv,
            },
            encryptionKey,
            encoded,
        );
    
        const exportedKey = await window.crypto.subtle.exportKey(
            'jwk',
            encryptionKey,
        );
    
        console.log("key (Base64url): " + exportedKey.k)
        console.log("iv (Base64): " + ab2b64(iv))
        console.log("ciphertext (Base64): " + ab2b64(cipher))
    
        // https://stackoverflow.com/a/11562550/9014097
        function ab2b64(arrayBuffer) {
            return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
        }
    
    })();

    Possible output:

    key (Base64url): 2TCb_J7EnsXgpYRhrJUG4ChgDNcnpcZ4sSCOK739U8A
    iv (Base64): cWXBcXDyEcKTSRi2zPsqrg==
    ciphertext (Base64): DRFokcfbdsfhNz/IeFUdmUQzxEAg09Y+gTE1DTfmzoA=
    

    On the PHP side the wrong mode is used, i.e. GCM must be replaced by CBC for compatibility with the JavaScript code (although GCM would actually be the more secure choice).
    Furthermore, the key must be Base64url (not Base64) decoded, while IV and ciphertext must be Base64 decoded. For the ciphertext an implicit Base64 decoding can be done by setting the 4th parameter of openssl_decrypt() to 0:

    <?php
    $keyB64url = "2TCb_J7EnsXgpYRhrJUG4ChgDNcnpcZ4sSCOK739U8A";
    $keyB64 = str_replace(['-','_'], ['+','/'], $keyB64url );
    $key = base64_decode($keyB64);
    $iv = base64_decode("cWXBcXDyEcKTSRi2zPsqrg==");
    $payload = "DRFokcfbdsfhNz/IeFUdmUQzxEAg09Y+gTE1DTfmzoA=";
    $dec = openssl_decrypt($payload, 'AES-256-CBC', $key, 0, $iv);
    var_dump($dec); // string(21) "Hello this is a test."
    ?>
    

    Edit:

    While CBC only provides confidentiality, GCM provides confidentiality and authenticity/integrity, making GCM more secure. Note that with CBC, a message authentication code (MAC) can be used so that (in addition to confidentiality) also authenticity is provided; however, the advantage of GCM is that this is done implicitly.

    For GCM on the JavaScript side the algorithm in generateKey() and encrypt() must be changed from AES-CBC to AES-GCM. The recommended length of the nonce for GCM is 12 bytes (although other nonce lengths including 16 bytes are supported), which requires a corresponding change in getRandomValues():

    (async () => {
    
        const encoder = new TextEncoder();
        const encoded = encoder.encode('Hello this is a test.');
    
        const encryptionKey = await window.crypto.subtle.generateKey(
            {
                name: 'AES-GCM',
                length: 256,
            },
            true,
            ['encrypt', 'decrypt'],
        );
    
        const iv = window.crypto.getRandomValues(new Uint8Array(12));
        const cipher = await window.crypto.subtle.encrypt(
            {
                name: 'AES-GCM',
                iv,
            },
            encryptionKey,
            encoded,
        );
    
        const exportedKey = await window.crypto.subtle.exportKey(
            'jwk',
            encryptionKey,
        );
    
        console.log("key (Base64url): " + exportedKey.k)
        console.log("iv (Base64): " + ab2b64(iv))
        console.log("ciphertext (Base64): " + ab2b64(cipher))
    
        // https://stackoverflow.com/a/11562550/9014097
        function ab2b64(arrayBuffer) {
            return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
        }
    
    })();

    A possible output is:

    key (Base64url): jno7Ydkris18yDtJ2nvPeWBrdiPqmqoZheYcc0qpjO8
    iv (Base64): zETSdleg3nYdTbDv
    ciphertext (Base64): HiPRcKl3MhzG+U4gKnpnK44hl9jqIzunMd15WnM9l4XkCjylXg==
    

    As mentioned above, GCM is an authenticated encryption mode and uses a tag for authentication. WebCrypto implicitly concatenates ciphertext and tag (16 bytes long by default) in this order, while PHP processes both separately. Therefore, ciphertext and tag must be separated on the PHP side:

    <?php
    $keyB64url = "jno7Ydkris18yDtJ2nvPeWBrdiPqmqoZheYcc0qpjO8";
    $keyB64 = str_replace(['-','_'], ['+','/'], $keyB64url );
    $key = base64_decode($keyB64);
    $iv = base64_decode("zETSdleg3nYdTbDv");
    $payload = base64_decode("HiPRcKl3MhzG+U4gKnpnK44hl9jqIzunMd15WnM9l4XkCjylXg==");
    $payloadLen = strlen($payload);
    $ciphertext = substr($payload, 0, $payloadLen - 16);
    $tag = substr($payload, $payloadLen - 16, 16);
    $dec = openssl_decrypt($ciphertext, 'AES-256-GCM', $key, OPENSSL_RAW_DATA, $iv, $tag);
    var_dump($dec); // string(21) "Hello this is a test."
    ?>