Guide to Node’s crypto module for encryption/decryption

8kzk...TxAo
13 Jan 2024
78

Encryption operations can be tricky, so much that paid encryption-as-a-service companies exist just to ensure that cryptographic operations are implemented correctly in codebases. The good news is that, with some little learning, you can make do with proper encryption for free with Node’s built-in crypto module.
In this guide, we explore how you can use Node’s built-in crypto module to correctly perform the (symmetric) encryption/decryption operations to secure data for your applications.
To begin, we need to understand the concept of symmetric encryption.

Symmetric Encryption

When people talk about “encryption,” they tend to mean symmetric encryption which is useful for encrypting text into a random string of characters. A common scenario where this is relevant is encrypting user data on a server so that it’s stored “encrypted at rest” in a database.
In layman terms, symmetric encryption is when you take the text you want to encrypt (called the plaintext) and use a secret key with an encryption algorithm to output the encrypted text (called the ciphertext). The operation is reversible and so decryption is when we can use the same secret with the plaintext
Symmetric encryption
Looks easy right?
Unfortunately, when it comes time to implement symmetric encryption, developers get it wrong all the time oftentimes because there’re a lot to understand:

  • Encoding formats: Data can be encoded/decoded in many ways like base64 , hex , etc. These different representations often confuse developers when converting from one format to another.
  • Algorithms and configuration: There are many encryption algorithms to consider from like aes-256-gcm or aes-256-cbc , each with their own requirements.
  • Randomness: Keys used in encryption procedures should be generated randomly to ensure high entropy. Often times, developers think they’re generating sufficiently-random keys but they’re not.
  • Complexity: Encryption in Node.js involves a few steps that aren’t always intuitive to developers and some concepts are genuinely puzzling at first. Take, for instance, the concept of an initialization vector (IV); developers new to cryptography often re-use these across their encryption processes and this is a big no-no.

Anyways, in this article I won’t cover the above nuances since they’re more crypto-heavy (we’ll save this discussion for another time) but focus more on how we can perform encryption correctly with Node.js by example.
The best way to explain how to correctly perform the encryption is to demonstrate a proper implementation using the aes-256-gcm algorithm. Let’s start with encryption.

Encryption

const crypto = require('crypto');

const encryptSymmetric = (key, plaintext) => {
  const iv = crypto.randomBytes(12).toString('base64');
  const cipher = crypto.createCipheriv(
    "aes-256-gcm", 
    Buffer.from(key, 'base64'), 
    Buffer.from(iv, 'base64')
  );
  let ciphertext = cipher.update(plaintext, 'utf8', 'base64');
  ciphertext += cipher.final('base64');
  const tag = cipher.getAuthTag()
  
  return { ciphertext, tag }
}

const plaintext = "encrypt me";
const key = crypto.randomBytes(32).toString('base64');

const { ciphertext, iv, tag } = encryptSymmetric(key, plaintext);

To perform the encryption, we need two items:

  • plaintext: The text that you want to encrypt.
  • key: A 256-bit encryption key.

We get the following outputs from the encryption:

  • ciphertext: The encrypted text.
  • iv: A 96-bit initialization vector to provide the initial state for the encryption and allow the same key to be re-used with a different iv for future encryption operations.
  • tag: A piece of data generated during the encryption process to help verify that the encrypted text was not tampered with later during the decryption process.

Let’s walk through the code:

const plaintext = "encrypt me";
const key = crypto.randomBytes(32).toString('base64');

Here we define the input variables needed to perform the encryption. Beyond the text that you want to encrypt, it’s important to generate the key randomly to ensure security via higher entropy; I prefer using the built-in crypto.randomBytes() for generation. I also convert encode everything in base64 format since its easy to store conceptually.
Next, let’s dissect the encryption function:

const encryptSymmetric = (key, plaintext) => {
  // create a random initialization vector
  const iv = crypto.randomBytes(12).toString('base64');

  // create a cipher object
  const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);

  // update the cipher object with the plaintext to encrypt
  let ciphertext = cipher.update(plaintext, 'utf8', 'base64');

  // finalize the encryption process 
  ciphertext += cipher.final('base64');
  
  // retrieve the authentication tag for the encryption
  const tag = cipher.getAuthTag();
  
  return { ciphertext, iv, tag };
}

Here, the encryption function creates a new cipher during crypto.createCipheriv initialized with the algorithm aes-256-gcm (you can think of it as a type of encryption algorithm) and the key and iv . The next part loads the text that you want to encrypt plaintext in and performs the encryption and retrieves an authentication tag that you can use to check that the ciphertext was not tampered with during the decryption process.
The last thing you should know about encryption is how to handle the data. In the context of encrypting user data and storing it at rest, you’d want to store the encrypted data ciphertext , iv , and tag in the database and keep the secret key stored somewhere else securely such as as an environment variable on the server or better yet in a a dedicated secret manager like Infisical. When you want to retrieve the data, you can query for it from the database and decrypt it using the secret key.
Speaking of which, let’s dive into decryption. As before, let’s start with a proper implementation.

Decryption

const decryptSymmetric = (key, ciphertext, iv, tag) => {
  const decipher = crypto.createDecipheriv(
    "aes-256-gcm", 
    Buffer.from(key, 'base64'),
    Buffer.from(iv, 'base64')
  );
  
  decipher.setAuthTag(Buffer.from(tag, 'base64'));

  let plaintext = decipher.update(ciphertext, 'base64', 'utf8');
  plaintext += decipher.final('utf8');

  return plaintext;
}

const plaintext = decryptSymmetric(key, ciphertext, iv, tag);

To perform the decryption, we need four items:

  • key: The 256-bit encryption key used to encrypt the original text.
  • ciphertext: The encrypted text that you want to decrypt.
  • iv: The 96-bit initialization vector used during the encryption.
  • tag: The tag generated during the encryption.

We get the following outputs from the encryption:

  • plaintext: The original text that we encrypted.

Let’s walk through the decryption function:

const decryptSymmetric = (key, ciphertext, iv, tag) => {
  // create a decipher object
  const decipher = crypto.createDecipheriv(
    "aes-256-gcm", 
    Buffer.from(key, 'base64'),
    Buffer.from(iv, 'base64')
  );
  
  // set the authentication tag for the decipher object
  decipher.setAuthTag(Buffer.from(tag, 'base64'));

  // update the decipher object with the base64-encoded ciphertext
  let plaintext = decipher.update(ciphertext, 'base64', 'utf8');

  // finalize the decryption process
  plaintext += decipher.final('utf8');

  return plaintext;
}

Here, the decryption function creates a decipher object during crypto.createDecipheriv initialized with the algorithm aes-256-gcm , the key created earlier, and iv also created earlier. The next part sets the authentication tag to be used for checking to see if the ciphertext was tampered with and finally we perform the decryption to obtain the original text.
Horray!
While this all sounds complicated, I would encourage you to take some time to understand it and define helper functions for encryptSymmetric and decryptSymmetric like above so you can reuse them consistently across your codebase.
Lastly, your data is only as secure as the keys used to encrypt the data. As such, I strongly recommend securely storing encryption keys and accessing them in your application as environment variables or fetching them back from a secret manager like Infisical at runtime. This is simple to set up and worthwhile to learn.

Resources

Encryption/decryption can be a tricky subject, especially for folks with limited prior cryptography experience. So, before we part ways, I wanted to leave you with some resources:


Write & Read to Earn with BULB

Learn More

Enjoy this blog? Subscribe to AbdullahCoban✅

8 Comments

B
No comments yet.
Most relevant comments are displayed, so some may have been filtered out.