Build file encryption NextJS app with the browser crypto API

Build file encryption NextJS app with the browser crypto API

Building proper encryption is a hard task, it is easy to make it in a bad way, so if you planing building an encryption-based application you need to be prepared. It is extremely true if you use client-side encryption. You need to handle the password correctly, which is the essence of the encryption, so I recommend you if you are not familiar with this concept you should not build a production application on your own based on client-side encryption. But if you are interested in this and you want to start to learn about this, then this article is the best start for you. I am going to show you how browser-based encryption works, how you can build a front-end encryption functionality to make your files encrypted on the client-side. In this article, I am not going to cover safe password handling techniques because it is also a big topic on its own.

Why is it good?

But why is it good to build an encryption module on the client side? There are situations when we do not want to send the file to the backend without any encryption. If you build an application based on highly private files, for example about personal medical data or legal, or personal finance documents, you need to encrypt the files as soon as possible, on the client's computer. Fortunately, it is possible with the browser API, so you don’t need to build a native application for that.

Algorithms

Before we jump into the codes, let’s see a quick summary of the algorithms that we are going to use for the encryption.

PBKDF2 (Password-Based Derivation Function 2)

The PBKDF2 applies a pseudorandom function to a password along with a salt value and repeats the process much time to create a derived key. We are going to use this key for further operations.

AES-CBC

This means we are going to use the AES algorithm in Chiper Block Chaining Mode. AES (Advanced Encryption Standard) is a popular and widely adopted encryption algorithm. It is based on a substitution-permutation network, which means it comprises linked operations, some of which involve replacing inputs by specific outputs (substitution) and others involve shuffling bits around (permutation).

Setup

Because the Web Crypto API is a built-in browser API you don’t need any setup steps to able to use it. Specifically, I am going to use the SubtleCrypto interface.

The SubtleCrypto interface of the Web Crypto API provides a number of low-level cryptographic functions. Access to the features of SubtleCrypto is obtained through the subtle property of the Crypto object you get from window.crypto.

Important to note that not every browser supports completely the SubtleCrypto interface, you can check the details here.

For this demo, I am going to use React and TypeScript to build a basic user interface quickly, but these tools are not required for the API, you can use anything that you want.

npx create-next-app --typescript
cd browser-encryption

Then let’s create a file for the crypto functionalities. Basically, at first, we want to create a module that expects a file, a string passphrase as inputs, and returns a Uint8Array that contains the encrypted data of the input file. Of course, we can save it as text content but because it won’t be readable because of the encryption, it does not matter in the context of this demo application. So based on these, the function is going to be this:

export const encryptFile = async (file: File, passphrase: string): Promise<Uint8Array> => {…}

Before the actual encryption, we need to make some transformations with the file and the passphrase.

File transform

We are going to use the file in Uint8Array format, so let’s see the transformations. We need to define three functions for this:

/**
 * Convert a file to string or ArrayBuffer based on the FileReader
 * @param file      Input file that needs to convert
 * @returns      The input file as a String or an ArrayBuffer
 */
const readFile = (file: File): Promise<string | ArrayBuffer | null> =>
  new Promise((resolve, reject) => {
    const fr = new FileReader();
    fr.onload = () => {
      resolve(fr.result);
    };
    fr.readAsArrayBuffer(file);
  });

/**
 * This function transform a plain text bytes input to an Uint8Array
 * @param plainTextBytes     Bytes that the function transforms to Uint8Array
 * @returns     Uint8Array representation of the input bytes
 */
const getUint8ArrayFromBytes = async (plainTextBytes: string | ArrayBuffer): Promise<Uint8Array | null> => {
  try {
    let plainTextBytesUnitArray;

    if (typeof plainTextBytes === 'string') {
      if (!('TextEncoder' in window)) alert('Sorry, this browser does not support TextEncoder...');
      const encoder = new TextEncoder();
      plainTextBytesUnitArray = encoder.encode(plainTextBytes);
    } else {
      plainTextBytesUnitArray = new Uint8Array(plainTextBytes);
    }

    return plainTextBytesUnitArray;
  } catch (error) {
    return null;
  }
};

/**
 * The function transforms the input file to and Uint8Array
 * @param file      A file that the function transforms to Uint8Array
 * @returns      Uint8Array representation of the input file
 */
const getUint8ArrayFromFile = async (file: File): Promise<Uint8Array | null> => {
  const plainTextBytes = await readFile(file);
  if (!plainTextBytes) {
    throw new Error('Reading the file failed');
  }

  return getUint8ArrayFromBytes(plainTextBytes);
};

With these, we can transform our input file to a Uint8Array format that we can use later for the encryption. After this, we need one more module to define before the encryption.

CryptoKey from the password

This step will be the first encryption step in our application, we are going to define a function that creates a CryptoKey from our plain string passphrase.


/**
 * It receives a string key as the input and returns with a CryptoKey object for the Web Crypto API
 * @param passphrase        Plain secret key
 * @returns        CryptoKey based on the input passphrase
 */
const importPassphrase = async (passphrase: string): Promise<CryptoKey> => {
  const passphraseBytes = new TextEncoder().encode(passphrase);

  return window.crypto.subtle.importKey('raw', passphraseBytes, { name: 'PBKDF2' }, false, ['deriveBits']);
};

This is the first place where we are using the crypto API. Let's take a closer look. With the importKey method input parameters, we want to create a raw formatted key which means the output key is going to be in ArrayBuffer format. As a method, we are using the PBKDF2 algorithm which is designed to derive the key from some relatively low-entropy input, such as a password. The fourth parameter says we don't want to export the key and the last one is to define what can be done with the derived key.

Encryption

The code parts below are in the encryptFile function which we defined before. Let's see the encryption code in detail.

const plainTextBytesUnitArray = await getUint8ArrayFromFile(file);
if (!plainTextBytesUnitArray) {
    throw new Error('Reading the file failed');
}
…

Here we are just simply using the transform methods that we defined before. And then we construct our CriptoKey:

const passphraseKey = await importPassphrase(passphrase);
if (!passphraseKey) {
    throw new Error('Passphrasekey import failed');
}

// passphrasekey imported

I prefer to define the behavior of every bad path in this kind of important logic to avoid unexpected errors. If it would be a production application I would also write unit tests for these functionalities because this is a key module in this kind of application and we should do everything to make this safe.

const pbkdf2salt = window.crypto.getRandomValues(new Uint8Array(8));

let pbkdf2bytes = await window.crypto.subtle.deriveBits({
    name: 'PBKDF2',
    salt: pbkdf2salt,
    iterations: 10000,
    hash: 'SHA-256' 
}, passphraseKey, 384);

// pbkdf2bytes derived

At the start of the next section, we are generating a random number array which we are going to use as a salt vector for the PBKDF2 algorithm. We are going to use these bytes as the basic ingredients of the AES key. In the next section, we are going to generate the key from this vector. At first, we split the generated bytes into two pieces, the first 32 bit is key bytes and the remaining part is going to be the iv (Initialization vector) bytes for the encryption. I set the importKey method’s parameters to able to use it for the AES encryption, the length is 256 bytes, and the last parameter, the keyUsages is set to encrypt because we want to use this key to encrypt the file data.

…
pbkdf2bytes = new Uint8Array(pbkdf2bytes);

const keyBytes = pbkdf2bytes.slice(0, 32);
const ivBytes = pbkdf2bytes.slice(32);

const key = await window.crypto.subtle.importKey('raw', keyBytes, { name: 'AES-CBC', length: 256 }, false, [
  'encrypt',
]);

// key imported

The following section is the encryption. For this, we are using the generated initialize vector and the array formatted content of the file. After the encryption, we get the encrypted file data as a result.

let cipherBytes = await window.crypto.subtle.encrypt(
    { name: 'AES-CBC', iv: ivBytes },
    key,
    plainTextBytesUnitArray
);

if (!cipherBytes) {
    throw new Error('Error encrypting file.');
}

// plaintext encrypted

After this, we have only one last step. We need to construct the return value from the salt and the encrypted file data.

const cipherBytesUnitArray = new Uint8Array(cipherBytes);

const resultBytes = new Uint8Array(cipherBytesUnitArray.length + 16);
resultBytes.set(new TextEncoder().encode('Salted__'));
resultBytes.set(pbkdf2salt, 8);
resultBytes.set(cipherBytesUnitArray, 16);

return resultBytes;
…

I pushed the code into a Github repository, so you can check the remaining parts of the code. I built a minimal UI to able to try the encryption process on files and I implemented the decrypt methods too, so you can encrypt your files and then decrypt it to able to see that the encrypted file is not readable but with the right password, you can decrypt the file and read it again.

Summary

In this article, I wanted to show how you can build a client-side encryption module for your front-end application. Since the browser has native support for encryption we can build applications based on client-side encryption but I suggest you build production stuff on this technology only if you are familiar with the encryption methods, the right password storing methods, and every important aspect of cryptography.