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: