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');
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('');
};
#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');
};
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('');
};
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')}`;
};
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);
}
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.