javascriptphpgoogle-cloud-messagingaesphpseclib

PHP Encrypt AES-256-GCM using Phpseclib and Decrypt with Javascript using crypto.subtle


In order to achieve what i wrote as subject, i have this php code:

<?php
require 'vendor/autoload.php';
use phpseclib3\Crypt\AES; // Per l'encryptMessage
use phpseclib3\Crypt\Random; // Per la generateSharedKey

function generateSharedKey() {
    return base64_encode(Random::string(16));
}

function encryptMessage($messaggio_da_criptare, $chiave) {
    $aes = new AES('gcm');
    $aes->setKey(base64_decode($chiave)); // <---Decoding the key here solved the problem!!!
    $iv=base64_encode(openssl_random_pseudo_bytes(12));
    $aes->setNonce($iv);
    $testo_cifrato = $aes->encrypt($messaggio_da_criptare);
    $tag = $aes->getTag();
    $dati_criptati = ['messaggio_criptato_con_tag' => base64_encode($testo_cifrato.$tag),'iv' => $iv];
    return $dati_criptati;
}

function decryptMessage($messaggio_criptato_con_tag, $iv, $chiave) {
    $aes = new AES('gcm');
    $aes->setKey(base64_decode($chiave));
    $aes->setNonce($iv);
    $combined = base64_decode($messaggio_criptato_con_tag);
    $tagLength = 16; // GCM tag length is 16 bytes
    $tag = substr($combined, -$tagLength);
    $encryptedMessage = substr($combined, 0, -$tagLength);
    $aes->setTag($tag);
    $decryptedMessage = $aes->decrypt($encryptedMessage);
    return $decryptedMessage;
}

$messaggio = "Hello, this is a secret message!";
echo "Messaggio di esempio da criptare: ".$messaggio."<br>";
$chiave = generateSharedKey();
echo "Chiave segreta: ".$chiave."<br>";
$dati_criptati = encryptMessage($messaggio, $chiave);
echo "Messaggio criptato con tag incluso: ".$dati_criptati["messaggio_criptato_con_tag"]."<br>";
echo "IV: ".$dati_criptati["iv"]."<br>";
echo "Esito messaggio decriptato: ".decryptMessage($dati_criptati["messaggio_criptato_con_tag"], $dati_criptati["iv"], $chiave);
?>

and this html code

<!DOCTYPE html>
<html>
<script>
var phpkey = "TWw4QCkeZEnXoCDkI1GEHQ==";
var phpiv = "CRKTyQoWdWB2n56f";
var phpmessageencrypted = "7K+HAB7Ch9V4jJ1XJPM0sANXA2ocJok=";

(async () => {
    function _base64ToArrayBuffer(base64) {
        var binary_string = atob(base64);
        var len = binary_string.length;
        var bytes = new Uint8Array(len);
        for (var i = 0; i < len; i++) {
            bytes[i] = binary_string.charCodeAt(i);
        }
        return bytes.buffer;
    }
    async function _importKeyAes(key) {
        return await window.crypto.subtle.importKey("raw",key,{ name: "AES-GCM" },false,["encrypt", "decrypt"]);
    }
    async function decryptMessageSymetric(key, data, iv) {
        var keyArrayBuffer = _base64ToArrayBuffer(key);
        var keyt = await _importKeyAes(keyArrayBuffer);
        var ivt = new TextEncoder().encode(iv);
        var datat = _base64ToArrayBuffer(data);
        var result = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: ivt, tagLength: 128 },keyt,datat);
        return new TextDecoder().decode(result);
    }
    var result = await decryptMessageSymetric(phpkey, phpmessageencrypted, phpiv); 
    console.log(result);
})();
</script>
</html>

The problem is: If you launch the php script and test the result with an online tool like this: text It works and seems that the encryption step is correct.

If you launch the html page and you check with the inspector, with the provided example data it works.

But, if you replace:

var phpkey = "TWw4QCkeZEnXoCDkI1GEHQ=="; var phpiv = "CRKTyQoWdWB2n56f"; var phpmessageencrypted = "7K+HAB7Ch9V4jJ1XJPM0sANXA2ocJok=";

with the php given data, it doesn't work and browser give the following (and unuseful) error code: Uncaught (in promise) DOMException: The operation failed for an operation-specific reason while the online tool report this:

screenshot

I really don't know what i'm doing wrong. Probably something regarding the KEY and IV, but why they are accepted by the online tool and not by the html/js script?

FINAL WORKING SOLUTION

Thanks to @Topaco i've fixed the code, removing an unuseful base64encode/decode phase and renaming variable names in english. Below the working code.

PHP Encrypt/Decript functions

<?php
require 'vendor/autoload.php';
use phpseclib3\Crypt\AES; // For encryptMessage,decryptMessage

function generateSharedKey() {
    // Implement your logic here. Just return a 16 chars string
    $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-;:.';
    return substr(str_shuffle($characters), 0, 16);
}
function encryptMessage($message, $key) {
    $aes = new AES('gcm');
    $aes->setKey($key);
    $iv=base64_encode(openssl_random_pseudo_bytes(12));
    $aes->setNonce($iv);
    $encrypted_message = $aes->encrypt($message);
    $tag = $aes->getTag();
    $encrypted_data = ['encrypted_message_with_tag' => base64_encode($encrypted_message.$tag),'iv' => $iv];
    return $encrypted_data;
}

function decryptMessage($encrypted_message_with_tag, $iv, $key) {
    $aes = new AES('gcm');
    $aes->setKey($key);
    $aes->setNonce($iv);
    $combined = base64_decode($encrypted_message_with_tag);
    $tagLength = 16; // GCM tag length is 16 bytes
    $tag = substr($combined, -$tagLength);
    $encryptedMessage = substr($combined, 0, -$tagLength);
    $aes->setTag($tag);
    $decryptedMessage = $aes->decrypt($encryptedMessage);
    return $decryptedMessage;
}

$message = "Hello, this is a secret message!";
echo "Plain message: ".$message."<br>";
$key = generateSharedKey();
echo "Secret key: ".$key."<br>";
$encrypted_data = encryptMessage($message, $key);
echo "Encrypted message with tag: ".$encrypted_data["encrypted_message_with_tag"]."<br>";
echo "IV: ".$encrypted_data["iv"]."<br>";
echo "Decrypt result: ".decryptMessage($encrypted_data["encrypted_message_with_tag"], $encrypted_data["iv"], $key);
?>

Html+Javascript Decrypt example using php encrypted data

<!DOCTYPE html>
<html>
<script>
// Input data
var phpSecretKey = "joSigfZlF9cu2rBK";
var phpEncryptedMessageWithTag = "FwzV1FjL6T8AGydI+j/ybRbqGzVpnmz633Sj34pwMCPpo1dcb7brp4WilGqWsivD";
var phpIV = "xgwZ1lV5XKoCGurU";  // Generate this on every message for security reason

(async () => {
    function _base64ToArrayBuffer(base64) {
        var binary_string = atob(base64);
        var len = binary_string.length;
        var bytes = new Uint8Array(len);
        for (var i = 0; i < len; i++) {
            bytes[i] = binary_string.charCodeAt(i);
        }
        return bytes.buffer;
    }
    async function _importKeyAes(key) {
        return await
        window.crypto.subtle.importKey("raw", key, {
            name : "AES-GCM"
        }, false, ["encrypt", "decrypt"]);
    }
    async function decryptMessageSymetric(key, data, iv) {
        var keyArrayBuffer = _base64ToArrayBuffer(btoa(key));
        var keyt = await
        _importKeyAes(keyArrayBuffer);
        var ivt = new TextEncoder().encode(iv);
        var datat = _base64ToArrayBuffer(data);
        var result = await
        window.crypto.subtle.decrypt({
            name : "AES-GCM",
            iv : ivt,
            tagLength : 128
        }, keyt, datat);
        return new TextDecoder().decode(result);
    }
    // Execute
    var result = await decryptMessageSymetric(phpSecretKey, phpEncryptedMessageWithTag, phpIV);
    document.write(result);
})();
</script>
</html>

Test using online tool: (link HERE)

Here a screenshot with php example data:

enter image description here


Solution

  • The problem is that in the PHP code the Base64 encoded key is used directly, i.e. without Base64 decoding. If this is also done in the JavaScript code, decryption works, as the following example shows (the data was generated with the PHP code you posted):

    // Attention: works e.g. in Firefox, but not in Chrome and Chrome-based browsers, for an explanation see below!
    
    var phpkey = "YIUDaeHCjlX3gQb2nxFIxg==";
    var phpiv = "BwKuWNyG/PBKr4H/";
    var phpmessageencrypted = "J1DQz24HB6ka4tZhhz08hV3TrMIrms/+56ah8n2z3N02LeDEYWzIBIMH2BC6RcyW";
    
    (async () => {
        function _base64ToArrayBuffer(base64) {
            var binary_string = atob(base64);
            var len = binary_string.length;
            var bytes = new Uint8Array(len);
            for (var i = 0; i < len; i++) {
                bytes[i] = binary_string.charCodeAt(i);
            }
            return bytes.buffer;
        }
        async function _importKeyAes(key) {
            return await window.crypto.subtle.importKey("raw",key,{ name: "AES-GCM" },false,["encrypt", "decrypt"]);
        }
        async function decryptMessageSymetric(key, data, iv) {
            // var keyArrayBuffer = _base64ToArrayBuffer(key);
            var keyArrayBuffer = new TextEncoder().encode(key); // Fix!
            var keyt = await _importKeyAes(keyArrayBuffer);
            var ivt = new TextEncoder().encode(iv);
            var datat = _base64ToArrayBuffer(data);
            var result = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv: ivt, tagLength: 128 },keyt,datat);
            return new TextDecoder().decode(result);
        }
        var result = await decryptMessageSymetric(phpkey, phpmessageencrypted, phpiv); 
        console.log(result);
    })();

    Note that without Base64 decoding a 24 bytes key and thus AES-192 is applied! For this reason, the code does not work with Chrome and Chrome-based browsers, as they do not support AES-192 1. A possible browser in which the code can be run is Firefox.

    1WebCrypto fully supports AES and thus all three key sizes 128, 192 and 256 bits, see e.g. here, sec. Generate Key. Chrome only supports 128 and 256 bits, see e.g. here. In case of doubt, this site can be used to determine the compatibility of the respective browser.


    However, the use of AES-192 and especially the browser restriction is probably not what you want, but rather the use of AES-128 and the support of Chrome and Chrome-based browsers.
    The fix for this must be made in the PHP code: Since the key provided by generateSharedKey() is Base64 encoded, it must be Base64 decoded before encryption:

    ...
    $chiave = generateSharedKey();                                          // Base64 encoded key
    echo "Chiave segreta: ".$chiave."<br>";
    $dati_criptati = encryptMessage($messaggio, base64_decode($chiave));    // Fix: Base64 decode key before encryption
    ...
    

    A fix in the JavaScript code is then not necessary.