Search code examples
cryptographywebcrypto-apinode-cryptosubtlecrypto

How to decrypt NodeJS crypto on the client side with a known encryption key?


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.


Solution

  • 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).