DEV Community

Cover image for Migrating from Node.js crypto to Web Crypto API: A guided experience
Wang Sijie for Logto

Posted on • Originally published at blog.logto.io

Migrating from Node.js crypto to Web Crypto API: A guided experience

Introduction

The Web Crypto API is a new Javascript tool for cryptography. It is compatible with modern browsers and leading platforms, including Cloudflare Workers and Vercel, and also Node.js. Such interoperability implies a developer's dream - write your cryptography code once and run it across numerous platforms using the Web Crypto API.

However, Node.js already has a "crypto" module with a long history, leaving a significant amount of legacy code in need of migration. In this article, we will delve into the transition experience, providing a comprehensive guide focusing on 3 common scenarios. We aim to illuminate the pathway to successful migration.

What is Web Crypto API

Web Crypto API, an open standard by W3C for JavaScript, is a collection of standardised cryptography primitives defined in the Web Cryptography API specification. It was created after several browsers and platforms began adding their own non-interoperable cryptography functions.

The API provides primitives for key generation, encryption and decryption, digital signatures, key and bit derivation, and cryptographic digest. It is centered around an interface called SubtleCrypto, you can find more details and tutorials in the Mozilla MDN documentation.

But Node.js already has crypto module?

Node.js developers are typically familiar with the crypto module. It offers a comprehensive set of cryptographic primitives. This module not only provides mechanisms for the same cryptographic operations defined in the Web Crypto API but often includes a broader range of cryptographic algorithms.

So why do we still need Web Crypto API? As Javascript becomes popular across many platforms and environments , from the client-side to servers and especially on the edge, it's important to have a cross-platform cryptographic tool to simplify processes.

In addition, the functions in Web Crypto API standard all return promises and support the async/await syntax. This is a significant advantage over the crypto module, which is synchronous and can block the event loop.

And there is a interesting thing that Node.js adds its support for Web Crypto API, that means, in most cases, Web Crypto API is perfect in most known platforms.

Using Web Crypto API

On most platforms, the collection of Web Crypto APIs is accessible via the global crypto object, which includes 3 top-level utilities: getRandomValues, randomUUID and subtle.

There are many differences compared to the traditional crypto module. They can be summarized into 3 most common parts. Let’s go through them and see how to migrate from existing code.

#1 Generate random values

In crypto module, you can generate random values by calling randomBytes

import { randomBytes } from 'crypto';

const generateRandomString = (length = 64) => randomBytes(length).toString('hex');
Enter fullscreen mode Exit fullscreen mode

There is a simalar function called getRandomValues in Web Crypto, but the return value is an ArrayBuffer, so we need an another step to convert it to string.

const generateRandomString = (length = 64) => {
  const array = new Uint8Array(10);
  crypto.getRandomValues(array);
  return Array.from(array)
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
};
Enter fullscreen mode Exit fullscreen mode

#2 Hasing (or digest)

createHash is easy to use in crypto module:

export const sha256 = (text: string): string => {
  return createHash('sha256').update(text).digest('hex');
};
Enter fullscreen mode Exit fullscreen mode

In Web Crypto, we can use subtle.createHash

export const sha256 = async (text: string): Promise<string> => {
  const encoder = new TextEncoder();
  const data = encoder.encode(text);
  const hash = await crypto.subtle.digest('SHA-256', data);

  return Array.from(new Uint8Array(hash))
    .map((byte) => byte.toString(16).padStart(2, '0'))
    .join('');
};
Enter fullscreen mode Exit fullscreen mode

As you can see, the transformation from ArrayBuffer to hex string is also needed.

#3 Encryption and decryption

In crypto module, AES encryption and decryption can be implemented with:

export const encrypt = (text: string, password: string) => {
  const iv = randomBytes(16);
  const cipher = createCipheriv('aes-256-gcm', password, iv);
  const encrypted = cipher.update(text, 'utf8', 'base64');
  return {
    ciphertext: `${encrypted}${cipher.final('base64')}`,
    iv: iv.toString('base64'),
  };
};

export const decrypt = (ciphertext: string, iv: string, password: string) => {
  const decipher = createDecipheriv('aes-256-gcm', password, iv);
  const decrypted = decipher.update(encrypted, 'base64', 'utf8');
  return `${decrypted}${decipher.final('utf8')}`;
};
Enter fullscreen mode Exit fullscreen mode

In Web Crypto, we need to create key by importKey first:

async function encrypt(text: string, password: string) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encodedPlaintext = new TextEncoder().encode(text);

  const secretKey = await crypto.subtle.importKey(
    'raw',
    Buffer.from(await getKeyFromPassword(password, crypto), 'hex'),
    {
      name: 'AES-GCM',
      length: 256,
    },
    true,
    ['encrypt', 'decrypt']
  );

  const ciphertext = await crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv,
    },
    secretKey,
    encodedPlaintext
  );

  return {
    ciphertext: Buffer.from(ciphertext).toString('base64'),
    iv: Buffer.from(iv).toString('base64'),
  };
}

async function decrypt(ciphertext: string, iv: string, password: string) {
  const secretKey = await crypto.subtle.importKey(
    'raw',
    Buffer.from(await getKeyFromPassword(password, crypto), 'hex'),
    {
      name: 'AES-GCM',
      length: 256,
    },
    true,
    ['encrypt', 'decrypt']
  );

  const cleartext = await crypto.subtle.decrypt(
    {
      name: 'AES-GCM',
      iv: Buffer.from(iv, 'base64'),
    },
    secretKey,
    Buffer.from(ciphertext, 'base64')
  );

  return new TextDecoder().decode(cleartext);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

As you can see, the migration is not hard, the main job is to change the syntax to fit the new API and resolve ArrayBuffer with TextEncoder.

As an identity product, Logto uses cryptography in many places. We have migrated from the crypto module to the Web Crypto API. This transition enables us to better adapt to edge environments and makes it possible to execute SDK code securely in the browser.

Top comments (0)