1

I am trying to have AES encryption on the server side, and decryption on the client side. I have followed an example where CryptoJS is used on the client side for encryption and SubtleCrypto on the client side as well for decryption, but in my case I have the encryption and decryption separated.

Suppose I have the following encryption function within React Native:

const encrypt = (str: string) => {
  const iv = crypto.randomBytes(12);
  const myHexToken = "0x...."
  const cipher = crypto.createCipheriv('aes-256-gcm', myHexToken.slice(0,32), iv)
  let encrypted = cipher.update(str, 'utf8', 'hex')
  encrypted += cipher.final('hex');
  const tag = cipher.getAuthTag();

  return {
    message: encrypted,
    tag: tag.toString('hex'),
    iv: iv.toString('hex'),
  };
};

This json is then posted to the client through a webview postMessage.

The client side has the following javascript injected:

var myHexToken = "0x....";

window.addEventListener("message", async function (event) {
  var responseData = JSON.parse(event.data);
  try {
  var decryptedData = await decrypt(responseData.iv, responseData.message, responseData.tag);
  } catch (e) {
    alert(e);
  } 
  // ...

How can I decrypt responseData.message within the WebView through SubtleCrypto of the Web Crypto API?

I have tried various things with the following methods, but I keep getting "OperationalError":

function fromHex(hexString) { 
  return new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
}

function str2ab(str) {
  const buf = new ArrayBuffer(str.length);
  const bufView = new Uint8Array(buf);
  for (let i = 0, strLen = str.length; i < strLen; i++) {
    bufView[i] = str.charCodeAt(i);
  }
  return buf;
}

function fromBase64(base64String) {
 return Uint8Array.from(window.atob(base64String), c => c.charCodeAt(0));
}

async function importKey(rawKey) {
  var key = await crypto.subtle.importKey(
    "raw",
    rawKey,                                                 
    "AES-GCM",
    true,
    ["encrypt", "decrypt"]
  );
  return key;
}

async function decrypt(iv, data, tag) {
  var rawKey = fromHex(myHexToken.slice(0,32));
  var iv = fromHex(iv);
  var ciphertext = str2ab(data + tag);
  
  var cryptoKey = await importKey(rawKey)

  var decryptedData = await window.crypto.subtle.decrypt(
    {
      name: "AES-GCM",
      iv: iv
    },
    cryptoKey,
    ciphertext
  )
  
   var decoder = new TextDecoder();
   var plaintext = decoder.decode(decryptedData);

  return plaintext;
}

UPDATE 1: Added the getAuthTag implementation server side. Changed IV to have length of 12 bytes. Attempt to concatenate ciphertext and tag client side.

I have verified that "myHexToken" is the same both client and server side. Also, the return values of the server side "encrypt()" method are correctly sent to the client.

xfscrypt
  • 16
  • 5
  • 28
  • 59
  • 2
    As a starting point, your encryption code is "aes-256-gcm" and your decryption code is "AES-CBC". Switching modes is never going to work. My recommendation would be to use GCM on both sides, but they absolutely must match. Every piece of the encryption code must match the decryption code. – Rob Napier Feb 13 '23 at 00:44
  • 2
    The NodeJS code is missing the determination of the GCM tag via `getAuthTag()`. Also, for GCM the recommended IV/nonce length is 12 bytes (and not 16). Note that for GCM, unlike the crypto module, WebCrypto does not process ciphertext and tag individually, instead encryption returns and decryption requires the concatenated values of ciphertext and tag. – Topaco Feb 13 '23 at 07:46
  • Thanks @RobNapier. Apologies, this was a copy / paste error as I was trying various things. I have ensured client and server side use the same encryption algorithm. – xfscrypt Feb 13 '23 at 09:29
  • @Topaco Thank you for your insights. I have updated the code accordingly to the best of my ability. Sorry, relatively new to this, any further guidance is appreciated. For now still getting the OperationalError. Any idea what I am missing? – xfscrypt Feb 13 '23 at 09:31

1 Answers1

2

In the WebCrypto code the key must not be hex decoded with fromHex(), but must be converted to an ArrayBuffer with str2ab().
Also, the concatenation of ciphertext and tag must not be converted to an ArrayBuffer with str2ab(), but must be hex decoded with fromHex().

With these fixes decryption works:

Test:

For the test, the following hex encoded key and plaintext are used on the NodeJS side:

const myHexToken = '000102030405060708090a0b0c0d0e0ff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff';
const plaintext = "The quick brown fox jumps over the lazy dog";
const encryptedData = encrypt(plaintext);
console.log(encryptedData);

This results e.g. in the following output:

{
    message: 'cc4beae785cda5c9413f49cf9449a6ae17fdc0f7435b9a8fd954602bdb4f4b825793f6b561c0d9a709007c',
    tag: '046c8e56bbd13db2faed82d1b19c665e',
    iv: '11f87b0eaf006373ae8bc94d'
} 

The ciphertext created this way can be successfully decrypted with the fixed JavaScript code:

(async () => {

function fromHex(hexString) { 
    return new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
}

function str2ab(str) {
    const buf = new ArrayBuffer(str.length);
    const bufView = new Uint8Array(buf);
    for (let i = 0, strLen = str.length; i < strLen; i++) {
        bufView[i] = str.charCodeAt(i);
    }
    return buf;
}

async function importKey(rawKey) {
    var key = await crypto.subtle.importKey(
        "raw",
        rawKey,                                                 
        "AES-GCM",
        true,
        ["encrypt", "decrypt"]
    );
    return key;
}

async function decrypt(iv, data, tag) {
    //var rawKey = fromHex(myHexToken.slice(0,32)); // Fix 1
    var rawKey = str2ab(myHexToken.slice(0,32));
  
    var iv = fromHex(iv);
  
    //var ciphertext = str2ab(data + tag); // Fix 2
    var ciphertext = fromHex(data + tag);
  
    var cryptoKey = await importKey(rawKey)

    var decryptedData = await window.crypto.subtle.decrypt(
        {
            name: "AES-GCM",
            iv: iv
        },
        cryptoKey,
        ciphertext
    );
  
     var decoder = new TextDecoder();
     var plaintext = decoder.decode(decryptedData);

    return plaintext;
}

var myHexToken = '000102030405060708090a0b0c0d0e0ff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'
var data = {
    message: 'cc4beae785cda5c9413f49cf9449a6ae17fdc0f7435b9a8fd954602bdb4f4b825793f6b561c0d9a709007c',
    tag: '046c8e56bbd13db2faed82d1b19c665e',
    iv: '11f87b0eaf006373ae8bc94d'
} 
 
var plaintext = await decrypt(data.iv, data.message, data.tag);
console.log(plaintext);

})();

A remark about the key: In the posted NodeJS code, const myHexToken = "0x...." is set. It's not clear to me if the 0x prefix is just supposed to symbolize a hex encoded string, or is really contained in the string. If the latter, it should actually be removed before the implicit UTF-8 encoding (by createCiperiv()). In case of a hex decoding it must be removed anyway.
In the posted example a valid hex encoded 32 bytes key is used (i.e. without 0x prefix).


With regard to the key encoding, also note the following:

  • The conversion of the key from a hex encoded string by a UTF-8 (or ASCII) encoding results in only half of the key being considered, in the example: 000102030405060708090a0b0c0d0e0f. This reduces security, because the value range per byte is reduced from 256 to 16 values.
    In order for the entire key to be considered, the correct conversion on the NodeJS side would be: Buffer.from(myHexToken, 'hex') and on the WebCrypto side: var rawKey = fromHex(myHexToken).

  • Because of its implicit UTF8 encoding crypto.createCipheriv(..., myHexToken.slice(0,32), ...) creates a 32 bytes key and is functionally identical to str2ab(myHexToken.slice(0,32)) only as long as the characters in the substring myHexToken.slice(0,32) correspond to ASCII characters (which is true for a hex encoded string).

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • This is very helpful, really appreciate it! This is now working, thanks again for the time spent on this! If possible, I'd be interested in knowing why the 0x has to be removed, is it not considered a valid hex value? – xfscrypt Feb 14 '23 at 10:06
  • 1
    @apfz - `0x` is just a prefix that marks the following values as hex values, but does not belong to the value itself. Most hex decoding implementations do not accept the prefix either, e.g. in NodeJS `Buffer.from('0x...', 'hex')` does not work. – Topaco Feb 14 '23 at 12:36
  • 1
    @apfz - With UTF-8/ASCII encoding (as in your code), `0x` would be encoded (as byte sequence 0x30, 0x78), but would be unsuitable for a key since it is a constant value. However, UTF-8/ASCII encoding of a hex encoded byte sequence is generally the wrong approach for a key (as already stated in the answer). – Topaco Feb 14 '23 at 12:38