Digital Developer Conference on Cloud Native Security: Register for free and choose your sessions. June 24, 25, & July 1, 2020 Learn more

Securing JavaScript applications with the Web Cryptography API

As software becomes more modular in nature, the need to secure web applications is growing. Cryptography is becoming more important in modern application development, with numerous use cases, whether it’s end-to-end encryption in a messaging application, an authentication schema for online banking, or a proof of integrity for critical data.

Until fairly recently, browsers did not have any built-in cryptography APIs, leading to a large number of JavaScript libraries that implemented cryptography for web applications. No matter how well these libraries were designed and written, they were forced to use JavaScript, which turned out to be a particularly inept programming language for cryptographic algorithms due to its high-level nature, lack of 64-bit integer arithmetic, access to hardware features, and multi-threading.

In 2017, the World Wide Web Consortium (W3C) published the Web Cryptography API, which allows JavaScript applications in browsers to use common cryptographic features without having to use any third-party libraries. These features, whether provided through the Web Cryptography API or through the Node.js crypto module, are often referred to as “cryptographic primitives”. Even though these mechanisms are well-established and considered to be secure by themselves, it is easy to use them in an incorrect and possibly insecure manner.

In this blog post, I explain what’s included in the Web Crypto API, cover possible limitations, and talk about how to use it efficiently with the Node.js crypto module.

Design of the Web Cryptography API

At the time of writing, all popular browsers provide an implementation of the Web Crypto API to JavaScript applications through the semi-global crypto object. Let’s look quickly at the API and how to use it.

Obtaining cryptographically secure random data

The first noteworthy feature of Web Crypto is crypto.getRandomValues, which is currently the only way for web applications to obtain cryptographically secure random data:

const twentyBytes = crypto.getRandomValues(new Uint8Array(20));
console.log(twentyBytes);

crypto.subtle accesses all other features

All other features of the Web Crypto API are accessible through the crypto.subtle object. Unlike crypto.getRandomValues, all crypto.subtle functions return Promise objects as specified in the ECMAScript 2015 Language Specification, allowing browsers to perform required computations asynchronously in the background instead of blocking the JavaScript event loop.

Generic interfaces for using cryptographic algorithms

The Web Crypto API provides a set of generic interfaces to perform operations using various cryptographic algorithms, which are identified by standardized and mostly self-explanatory names such as AES-CTR, RSA-OAEP, SHA-256, and PBKDF2.

All operations accept an object identifying the algorithm and options, if necessary. For example, this code snippet generates an AES key, and then encrypts a message using the Cipher Block Chaining (CBC) mode:

const key = await crypto.subtle.generateKey(
  // The algorithm is AES in CBC mode, with a key length
  // of 256 bits.
  {
    name: 'AES-CBC',
    length: 256
  },
  // Allow extracting the key material (see below).
  true,
  // Restrict usage of this key to encryption.
  ['encrypt']
);

// AES-CBC requires a 128-bit initialization vector (iv).
const iv = crypto.getRandomValues(new Uint8Array(16));

// This is the plaintext:
const encoder = new TextEncoder();
const message = encoder.encode('Hello world!');

// Finally, encrypt the plaintext, and obtain the ciphertext.
const ciphertext = await crypto.subtle.encrypt(
  // The algorithm is still AES-CBC. In addition, the
  // 128-bit initialization vector must be specified.
  {
    name: 'AES-CBC',
    iv
  },
  // The encryption key. This must be an AES-CBC key,
  // otherwise, this function will reject.
  key,
  // The plaintext to encrypt.
  message
);

The Web Cryptography API uses instances of the ArrayBuffer class to represent byte sequences, but most functions also accept any TypedArray as their input. The result of crypto.subtle.encrypt will be an ArrayBuffer, and likely needs to be converted into a different data type or format to store or transmit the encrypted data.

Usage patterns correlate to best practices in cryptography

Unlike many other cryptographic libraries, the Web Crypto API enforces some usage patterns of keys that correlate to known best practices in cryptography. Keys can only be used and accessed through the CryptoKey class, which imposes certain restrictions: Key material can only be extracted from a CryptoKey if its extractable property was explicitly set to true during its creation.

Similarly, each CryptoKey has an algorithm and a usages attribute. If the key is used for an operation (e.g., to create a digital signature) within the context of an algorithm (e.g., RSASSA-PSS), the operation fails if the key’s algorithm is not equal to the requested algorithm, or if the key’s usages property does not contain the requested operation name.

These key properties are preserved when importing and exporting keys in the JSON Web Key (JWK) format, but not in any of the other supported formats. JWK is a JSON representation of the key material, and is therefore a good choice for storing or transmitting data in an environment that traditionally has very limited support for binary data.

Stateless, immutable Web Crypto objects

A good design aspect of the API is that, where other libraries often require objects to go through complex life cycles, all Web Crypto objects are stateless and immutable, making it impossible to accidentally use keys or algorithms in an invalid or unexpected state.

Limitations of the Web Crypto API

One of the current limitations of the Web Cryptography API is the missing support for streaming of any kind. Processing large amounts of data at once is usually relatively inefficient, which is why Node.js provides streaming interfaces for many cryptographic features, including symmetric encryption and decryption, hashing, signing, and signature verification.

Browser support for the Web Cryptography API varies, and even though all common browsers provide the API, there is no guarantee that a specific algorithm is supported in all browsers. If a web application requires a specific algorithm and is expected to work in browsers that might not support it, it might be necessary dynamically to switch to a JavaScript implementation (“polyfill”).

Some aspects of the API are unclear in the W3C recommendation, and the behavior of browsers differs. For example, Firefox 73, unlike Google Chrome 80, allows developers to create keys that are neither extractable nor usable, meaning that their extractable attribute is set to false and their usages attribute to an empty array. Similarly, the Web Crypto API does not require browsers to produce sensible error messages, and many browsers do not, leading to frustration when debugging code that uses the API.

The Web Crypto API recommendation also suggests non-standard features such as the length option for AES in Counter mode, which allows users to restrict the number of bits of the initialization vector that are used as the counter. These features are not available in all implementations, and some implementations may silently ignore such options, making feature detection difficult.

Compatibility with the Node.js crypto module

Official Node.js distributions support all Web Crypto API features, but the Node.js crypto module provides a different API, as well as different functions and classes. Web Crypto API functions are generally more generic. For example, all symmetric and asymmetric encryption are handled through crypto.subtle.encrypt, whereas Node.js provides separate functions crypto.createCipheriv, crypto.publicEncrypt, and crypto.privateEncrypt, depending on which kind of encryption needs to be performed.

In most cases, exchanging data between WebCrypto and the Node.js crypto API is relatively straightforward, but there are some exceptions. In some cases, the Web Crypto API represents data in a different way than Node.js. For example, authenticated symmetric encryption such as AES-GCM produces the ciphertext C and the authentication tag T as separate sequences in Node.js, but as a single concatenated sequence C | T in WebCrypto. Since T has a fixed size, the conversion between the separate values and the concatenated sequence is simple to implement in JavaScript.

The exchange of keys between the Web Cryptography API and Node.js is currently limited by the lack of support for the JSON Web Key (jwk) format within Node.js. However, the other key encoding formats used by the Web Crypto API (that is, spki, pkcs8, and raw) are generally well-supported and offer a suitable replacement.

The Node.js team has recently added a variety of features to the Node.js crypto module to provide better compatibility, for example, support for RSASSA-PSS signatures, custom hash functions for RSA-OAEP, functions for asymmetric key pair generation, and conversions between different key and signature formats. With these additions to Node.js, web applications can rely on excellent interoperability between Node.js and modern web browsers.

Using the Web Crypto API in Node.js

A small number of third-party implementations of the Web Cryptography API for Node.js exists, and the Node.js team is in the process of assessing the potential of the Web Crypto API for Node.js applications, including a prototype implementation written in JavaScript.

One benefit of using the Web Crypto API is the ability to reuse the same code for browser-based and Node.js applications. However, some characteristics of the web standard are potentially better suited for browsers than for Node.js, including the lack of support for streaming. The use of Promise objects makes sense in modern JavaScript, but may cause a considerable overhead on a server when running a lot of short-lived cryptographic operations, which can be as fast as a single function call in the Node.js crypto module. It also appears inconvenient that the Web Crypto API works on ArrayBuffer instances, whereas Node.js applications mostly operate on instances of the Buffer class.

The immediate goal of the Node.js team is to provide interoperability between the Web Cryptography API and the existing Node.js crypto module, and so far, it is looking really good. Cryptography, JavaScript, and Node.js are quickly changing technologies, and standards such as the Web Cryptography API will need to adapt accordingly, just like the implementations.

Tobias Nießen