Replacing CryptoJS with Web Cryptography for AES

Alternative title: You Might Not Need CryptoJS. In this post I’ll show how to replace CryptoJS’ AES module with the Web Cryptography API for improved performance and security.

With the spread of Service Worker and PWAs, a growing number of projects will have encrypted client-side storage as a requirement. In this post we’ll examine a commonly used JS cryptography library, do some basic due diligence on it, and then show a path towards replacing it with the browser’s own Web Cryptography API.

Basic Due Diligence

CryptoJS is a popular library for doing cryptography in the browser. After doing some basic research, I’m convinced that it is not a production-quality cryptography library. Not by a long shot.

What I found is a library of quasi-unknown origin, maintained by an amateur, unsafe default parameters, no bug bounty program, no published audit of any kind, and no discernable source of income. These are like the Greatest Hits of broken cryptography libraries.

The fact that it is downloaded close to 1 million times per week speaks volumes to the sophistication of the average npm user and the lack of engineering standards in the frontend community at large.

But there are exceptions. I’m more than excited to present you with this gem: A Responsible Developer’s Encounter with CryptoJS in Two Acts.

A Path Forward

In this post we’ll be replacing CryptoJS’ AES module with the Web Cryptography API. The biggest challenge when phasing out CryptoJS is dealing with data previously encrypted by it. We can keep the dependency around for that purpose, but personally I’d rather delete it immediately and use standard APIs for decrypting old data instead.

CryptoJS uses the standard AES-CBC algorithm which also ships as part of the Web Cryptography API. Web Crypto only includes a single padding scheme for non-block-sized payloads, but it’s the same one used by CryptoJS by default.

However it gets more complicated with respect to key derivation. A peak under the hood reveals that the algorithm to derive a key from the passphrase is not standardized and therefore not part of Web Crypto, and it uses the broken MD5 hash that is also not part of Web Crypto. Unfortunately, we’ll have to support this in order to decrypt old data, but we can take some measures to prevent make it harder to abuse.

Overview

Our high level function is going to look like this:

async function decryptCryptoJSCipherBase64(cryptoJSCipherBase64, password, { 
  keySizeDWORD = 256 / 32, 
  ivSizeDWORD = 128 /32,
  iterations = 1,
} = {}) {
  const { salt, ciphertext } = parseCryptoJSCipherBase64(cryptoJSCipherBase64);

  const { key, iv } = await dangerouslyDeriveParameters(password, salt, keySizeDWORD, ivSizeDWORD, iterations);
  const plaintextArrayBuffer = await crypto.subtle.decrypt({ name: "AES-CBC", iv }, key, ciphertext);

  return new TextDecoder().decode(plaintextArrayBuffer);
}

A couple of notes:

  • The default parameters are taken from CryptoJS.
  • I assume the cleartext is a string. If not you’ll have to remove the text decoding part.
  • We accept the cipher in base64 because that’s the default returned by CryptoJS’ toString function. You’ll have to adjust the code slightly to accept other formats.
  • I’m using verbose variable names because it’s really easy to mess up types and encodings otherwise.
  • All sizes in this article are given in multiples of 32-bit. I chose this unit because it is also used by CryptoJS, making it easier for the fast-moving npm-hacker to copy-paste the code and get it to work. To communicate this admittedly odd choice to everyone else, I borrow the DWORD designation from Microsoft where it is commonly known to be 32 bits.
  • I follow the React-tried-and-true practice of giving dangerous functions a verbose and uncomfortable name.

Next we look at parsing the input ciphertext. CryptoJS prefixes the ciphertext with Salted__ (exactly 64 bits), followed by a 64-bit salt.

+----------+----------+--------------------------------------------------------
| Salted__ |  <salt>  | <ciphertext...
+----------+----------+--------------------------------------------------------

To retrieve the salt and ciphertext as Uint8Arrays we use the following. I should note that the following code is mostly extracted from CryptoJS and rewritten using modern JS idioms and APIs, specifically typed arrays.

const HEAD_SIZE_DWORD = 2;
const SALT_SIZE_DWORD = 2;

function parseCryptoJSCipherBase64(cryptoJSCipherBase64) {
  let salt;
  let ciphertext = base64ToUint8Array(cryptoJSCipherBase64);

  const [head, body] = splitUint8Array(ciphertext, HEAD_SIZE_DWORD * 4);

  // This effectively checks if the ciphertext starts with 'Salted__'.
  // Alternatively we could do `atob(cryptoJSCipherBase64.substr(0, 11)) === "Salted__"`.
  const headDataView = new DataView(head.buffer);
  if (headDataView.getInt32(0) === 0x53616c74 && headDataView.getInt32(4) === 0x65645f5f) {
    [salt, ciphertext] = splitUint8Array(body, SALT_SIZE_DWORD * 4);
  }

  return { ciphertext, salt };
}

I’ll provide the helper functions at the end, but it’s suffice to say that they do exactly what they say they do (and they only work for Uint8Arrays).

Notice the multiplications by 4 to go from DWORDs to bytes, and the offset of 4 on getting the second Int32 value for the same reason.

Next we shift our attention towards the enigmatic dangerouslyDeriveParameters function. This is where we take a cryptographically weak passphrase and turn it into a supposedly strong cryptographic key. Given the default parameters this is not actually the case.

async function dangerouslyDeriveParameters(password, salt, keySizeDWORD, ivSizeDWORD, iterations) {
  const passwordUint8Array = new TextEncoder().encode(password);

  const keyPlusIV = dangerousEVPKDF(passwordUint8Array, salt, keySizeDWORD + ivSizeDWORD, iterations);
  const [rawKey, iv] = splitUint8Array(keyPlusIV, keySizeDWORD * 4);

  const key = await crypto.subtle.importKey("raw", rawKey, "AES-CBC", false, ["decrypt"]);

  return { key, iv };
}
  • Note how both the key and IV are derived from the password. I think this would be okay if the KDF wasn’t so weak and the salt is random and unique, but this is not something the Web Cryptography API’s deriveKey function would support if it implemented the EVPKDF.
  • We only allow the key to be used for decryption. This is to prevent accidental or intentional use for encryption, for which it is too weak.

Next we turn to the dangerousEVPKDF function. It is the same key derivation function used by OpenSSL (keyword: “EVP_BytesToKey”) and not dangerous by itself, but in the case of hard-coding MD5 as its hash function it certainly is.

This is the only part where we rely on an external dependency because MD5 is not provided by the Web Cryptography API. I’m using js-md5 here because it supports array buffers, but did not do any research on it otherwise. I figured there’s no such thing as a safe MD5 implementation anyway. You have been warned.

import * as md5 from 'js-md5'

function dangerousEVPKDF(passwordUint8Array, saltUint8Array, keySizeDWORD, iterations) {
  let derivedKey = new Uint8Array();
  let block = new Uint8Array();

  while (derivedKey.byteLength < keySizeDWORD * 4) {
    block = md5.arrayBuffer(concatUint8Arrays(block, passwordUint8Array, saltUint8Array));

    for (let i = 1; i < iterations; i++) {
      block = md5.arrayBuffer(block);
    }

    block = new Uint8Array(block);

    derivedKey = concatUint8Arrays(derivedKey, block);
  }

  return derivedKey;
}

Note that this function could be made much more time and space efficient by pre-allocating and reusing typed arrays. However, it was easier to get it right this way, and it should also be easier to follow along.

Usage

That’s it. We can now decrypt data perviously encrypted with CryptoJS at a fraction of the code size, mostly asynchronously, and mostly using fast native code.

import AES from 'crypto-js/aes';

const cleartext = "This is a message to be encrypted and decrypted";
const password = "passw0rd!";
const cryptoJSCipherBase64 = AES.encrypt(cleartext, password).toString();

decryptCryptoJSCipherBase64(cryptoJSCipherBase64, password).then(x => {
  console.log(x);
  console.log(x === cleartext);
});

As promised, here is the link to the full example including utility functions. Keep in mind that these were not written with performance in mind and could all be improved.

In Part II we will deal with deriving a stronger key from the same passphrase and encrypting the data again. This is mostly run-of-the mill Web Crypto code and examples are readily available on the web. However, there’s some specialty involved with respect to dealing with the Salted__ prefix and integrating this nicely with the framework we’ve established in this post.