Encryption, Decryption and Hashing functions based on Web Crypto API

Enabling basic cryptographic functions performing on Next.js Edge Runtime with Web Crypto API.

Tuedo#015

 by  tuedodev

22
Feb 2024
 0

Fortunately, the crypto module is now a native part of Node JS, but some JS frameworks such as Next JS, which rely on Edge Runtime for performance reasons, do not support some of those Node.js runtime modules.

This can be solved by using the Web Crypto API, which normally provides basic cryptographic functionalities on the browser side and is also supported by Next JS while running on the edge runtime.

Such simple cryptographic functions for encrypting, decrypting and hashing are useful, for example, for storing lower level sensitive data on the browser (conceivable, for example, as a session token for storing the user name or the preferred CSS theme).

The increasing tendency of JavaScript frameworks to move basic logic to the server side via SSR and to only send HTML, CSS and as little JavaScript as possible to the client also gives us an additional layer of security so that more robust and secure web applications can be programmed.

Quite simply because the potential attacker does not know which cryptographic methods and algorithms are being used. Which brings us to an important point: even if the Web Crypto API generally enables cryptographic functions in the browser, as much program logic as possible should be shifted to the server side. The security of the cryptographic algorithm relies on the secrecy and strength of the private key which is used.

Server actions in Next JS, which can invoke asynchronous functions on the server from the client, make it possible to hide any sensitive program logic. It is programmatically prevented from the very beginning that the private key leaks to the client. The 'use server' at the beginning of those cryptographic methods is a guarantee of this.

Since server actions are considered quite stable now as asynchronous functions of Vercel in Next JS, encrypting, decrypting and validating can (and should!) be done quite easily on the server side. We can break this down below into the four most important cryptographic functions cipherText, decipherText, hashPassword and compare.

Encryption of text

'use server';

type CryptoResult = {
	success: boolean;
	error: boolean;
	data: string;
	errorMsg: string;
};
export async function cipherText(text: string): Promise {
	return new Promise(async (resolve, _) => {
		try {
			const hashedSecretKey = await getHashedKey();
			const iv = crypto.getRandomValues(new Uint8Array(12)); // 16 characters long
			const algorithm = { name: 'AES-GCM', iv: iv };
			const encryptKey = await crypto.subtle.importKey('raw', hashedSecretKey, algorithm, false, ['encrypt']);
			const encrypted = await crypto.subtle.encrypt(algorithm, encryptKey, new TextEncoder().encode(text));
			const encryptedBase64 = Buffer.from(encrypted).toString('base64');
			const ivBase64 = Buffer.from(iv).toString('base64');
			resolve({
				success: true,
				error: false,
				errorMsg: '',
				data: `${encryptedBase64}${ivBase64}`,
			});
		} catch (error) {
			resolve({
				success: false,
				error: true,
				errorMsg: `${error}`,
				data: ``,
			});
		}
	});
}

The encryption function cipherText first retrieves a private key stored in the .env file1 and hashes it to a uniform length. The algorithm is then defined as well as an initialization vector (IV), basically a randomly generated byte code, in order to increase cryptographic variants and thus security. With a crypto key created in this way, the text to be encrypted is encrypted and converted into a base64 string which is easier to transfer and store. The previously generated IV (which does not necessarily have to remain secret) is simply appended to the generated string; it enables the text to be decrypted later by using this very IV combined with the secret private key.

Decryption of the cipher

Deciphering the text is more or less breaking down the stored cipher into its components. The IV is extracted as a substring (it always has the same length), a new CryptoKey is generated for decryption and the cipher text is translated back into its original state. It is important to note at this point that bytecodes, byte arrays, buffers and base64 strings can have different lengths; it is therefore important to take this into account when programming the functions and processing the strings and byte arrays. Because a decrypt function that is provided with incorrect values throws an error, the decoding must be encapsulated in a try-catch block.

export async function decipherText(cipherText: string): Promise {
	return new Promise(async (resolve, _) => {
		try {
			const cipher = cipherText.slice(0, cipherText.length - 16); // 44
			const iv = cipherText.slice(cipherText.length - 16); // 44
			const hashedSecretKey = await getHashedKey();

			const ivBuffer = Buffer.from(iv, 'base64');
			const algorithm = { name: 'AES-GCM', iv: ivBuffer };
			const decryptKey = await crypto.subtle.importKey('raw', hashedSecretKey, algorithm, false, ['decrypt']);
			const decrypted = await crypto.subtle.decrypt(algorithm, decryptKey, Buffer.from(cipher, 'base64'));
			const decryptedText = new TextDecoder().decode(decrypted);
			resolve({
				success: true,
				error: false,
				errorMsg: '',
				data: decryptedText,
			});
		} catch (error) {
			resolve({
				success: false,
				error: true,
				errorMsg: `${error}`,
				data: ``,
			});
		}
	});
}

Hashing a password and compare it with a new input

The basic characteristic of a hashing function is that it is a one-way street. The hashing leads to an unreadable base64 string with a uniform length, but it is not possible to transfer it back to the original text (i.e. the password). In addition, hashing is always deterministic, i.e. a certain text always leads to the same result.

export async function hashPassword(password: string): Promise {
	const salt = crypto.getRandomValues(new Uint8Array(18));
	const storedSalt = Buffer.from(salt).toString('base64');
	const passwordSalted = new TextEncoder().encode(password + storedSalt);
	const hashPasswordSalted = await crypto.subtle.digest('SHA-256', passwordSalted);
	const hashPasswordSaltedBase64 = `${Buffer.from(hashPasswordSalted).toString('base64')}${storedSalt}`;
	return hashPasswordSaltedBase64; // Last 24 characters are the salt
}

As a result, we only check whether a password entered generates the same hash code as the saved password; if the two hash strings are identical, the two passwords must therefore also be the same. This makes validation possible without having to save the original text of the password, which represents a considerable security gain, because even with a stolen hash code of a password from a database, unauthorized entry is not possible.

With an additional salting (again a randomly generated byte code), the cryptographic variance is increased and stored together with the hash code (in the same way as the IV mentioned above).

Salting helps fend off rainbow table attacks that use the hash code dictionaries of frequently used passwords to gain unauthorised access.

The comparison between two passwords then takes place between the old salt (which the attacker does not know because it is stored together with the old password in the database) and the newly entered string. The new hashing must lead to the same result if the password is valid.

export async function compare(password: string, storedPassword: string): Promise {
	const oldStoredSalt = storedPassword.slice(storedPassword.length - 24);
	const passwordSalted = new TextEncoder().encode(password + oldStoredSalt);
	const passwordSaltedBase64 = Buffer.from(await crypto.subtle.digest('SHA-256', passwordSalted)).toString('base64');
	return storedPassword.slice(0, storedPassword.length - 24) === passwordSaltedBase64;
}

You can follow those basic cryptographic functions in a small Next JS web app, the source code is available in the Github repository, a demo version can be found in a web container on Stackblitz (with limited functionality regarding unit testing and certain copy functions in the browser).

Github Repository of the Code

StackBlitz

Check it inside a Stackblitz WebContainer

  1. A quick and easy way to get a basic randomized string is to type in following command in your shell:
    openssl rand -base64 32 .

This post has not been commented on yet.

Add a comment