DEV Community

Cover image for Implementing symmetric and asymmetric encryption with NodeJS
Vitor Vargas for SuperViz

Posted on

Implementing symmetric and asymmetric encryption with NodeJS

In a digital world, where data is transmitted everywhere, concerns about where this data ends up are growing fast. Many companies offer services without encryption, leaving sensitive data exposed to unnecessary risks. Every year, we witness personal data leaks, and that is why at SuperViz, we have done work to guarantee the security and privacy of our users' data.

After much study and being responsible for implementing it internally in the company, I would like to share this article to help reduce such incidents. At the end of the article, you will be able to create encryptions using symmetric keys and using asymmetric keys.

Asymmetric and Symmetric Keys

Before diving into encryption, it's extremely valuable to know the difference between asymmetric and symmetric keys.

  • Symmetric Encryption:
    • Uses a single key for both encrypting and decrypting data.
    • The same key is shared between the sender and the receiver of the message.
    • It is faster and more efficient in terms of processing than asymmetric encryption.
  • Asymmetric Encryption (or public key encryption):
    • Uses a pair of keys: a public key and a private key;
    • The public key is used to encrypt the data, while the private key is used to decrypt the data;
    • Each recipient has their own private key and a public key, with only the public key being shared with other interested parties;
    • It is more secure than symmetric encryption due to the separation of public and private keys;
    • It is slower and requires more computational resources than symmetric encryption.

In summary, the main difference between symmetric and asymmetric encryption lies in how the keys are generated and used to encrypt and decrypt data, as well as in the relative efficiency and security of each approach.

Encrypting with Symmetric Keys

In this article we will use the AES-256-CBC encryption, with CBC being the acronym for Cipher Block Chaining.

CBC is a way of encrypting information. Imagine you have a message that you want to keep secure, with CBC it works like this:

  1. Divide the message into small pieces, called blocks;
  2. Instead of simply encrypting each block separately, as is the case in some methods, in CBC each block is mixed with the previous block before being encrypted. It's as if each block depended on what came before it;
  3. This creates a kind of "chain" of blocks, where each block is linked to the previous one;
  4. This link between the blocks makes it more difficult for someone who does not have the correct key to decipher the message. Even if someone tries to tamper with one block, it will affect all subsequent blocks, making decipherment much more difficult.

Knowing how CBC works, let's create our first message encrypted with AES-256-CBC! Imagine that we are going to create a function called encrypt, it will be responsible for creating the symmetric key and performing the encryption, it will have the following parameters: salt, passphrase, text.

The passphrase will be required to use in encryption to decrypt the content. Additionally, we will need a salt, which is a random text used as a basis for encrypting the data. This salt will increase the security of the encryption, making it more difficult for third parties to decipher protected content.

In the first line of the function, we will generate the symmetric key. This key will be the only key used to both encrypt and decrypt the content. It will be shared between the sender and the recipient.

import * as crypto from 'crypto'

const salt = randomBytes(32).toString('hex');
const passphrase = 'password';

const encrypt = (passphrase, salt, text) => {
  const key = scryptSync(passphrase, salt, 32)
  ...
}

Enter fullscreen mode Exit fullscreen mode

In line 7, we create our symmetric key. In the next line, we will need to generate the Initialization Vector. The Initialization Vector (IV) is like a secret ingredient in encryption, it is a random value that joins with the key to scramble the data, which makes encryption more secure and difficult to break.

const iv = crypto.randomBytes(16)

Enter fullscreen mode Exit fullscreen mode

Now let's encrypt the message using our key and iv.

  1. We will create our cipher passing 3 parameters: the algorithm, the key and the iv;
  2. We add the text to the cipher and change the text format from utf-8 to hex;
  3. We obtain the final encrypted content.
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')

encrypted += cipher.final('hex')
Enter fullscreen mode Exit fullscreen mode

We are approaching the end, but a challenge arises. To decrypt the message content, we need the IV (Initialization Vector), however, it is crucial that each IV is unique and random. Storing the IV alongside the encrypted message would compromise security. A commonly adopted solution is to merge the IV with the encrypted message in strategic locations, keeping its position confidential.

const part1 = encrypted.slice(0, 17)
const part2 = encrypted.slice(17)

return `${part1}${iv.toString('hex')}${part2}`
Enter fullscreen mode Exit fullscreen mode

It is important to point out that, in parts 1 and 2, our encrypted text contains only 32 characters.

And in the end, our encryption function looked like this:

import * as crypto from 'crypto'

const salt = crypto.randomBytes(32).toString('hex');
const passphrase = 'password';

const encrypt = (passphrase, salt, text) => {
  const key = crypto.scryptSync(passphrase, salt, 32)
  const iv = crypto.randomBytes(16)

  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
  let encrypted = cipher.update(text, 'utf8', 'hex')

  encrypted += cipher.final('hex')

  const part1 = encrypted.slice(0, 17)
  const part2 = encrypted.slice(17)

  return `${part1}${iv.toString('hex')}${part2}`
}

Enter fullscreen mode Exit fullscreen mode

Decrypting with Symmetric Keys

Now that we have our content encrypted at some point, we will need to decrypt the content. Some steps will be like what we did when encrypting the content. First let's recover our key.

import * as crypto from 'crypto'

const salt = crypto.randomBytes(32).toString('hex');
const passphrase = 'password';

const decrypt = (passphrase, salt, text) => {
  const key = crypto.scryptSync(passphrase, salt, 32)
  ...
}

Enter fullscreen mode Exit fullscreen mode

Next, to define the positions that we place when encrypting our content, which in the case of this article we are using position 17:

const ivPosition = {
  start: 17,
  end: 17 + 32
}

const iv = Buffer.from(text.slice(ivPosition.start, ivPosition.end), 'hex')
Enter fullscreen mode Exit fullscreen mode

Having the IV in hand, just get the encrypted content:

const part1: string = text.slice(0, ivPosition.start)
const part2: string = text.slice(ivPosition.end)

const encryptedText = `${part1}${part2}`
Enter fullscreen mode Exit fullscreen mode

Now we have both the encrypted content and the IV, we can then begin the process of deciphering the content. The process is similar to encryption:

  1. We will create our decryptor passing three parameters: the algorithm, the key and the iv;
  2. we add the cipher text and change the text format from hex to utf-8;
  3. We obtain the final deciphered content.
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')

return decrypted

Enter fullscreen mode Exit fullscreen mode

In the end, we have a code similar to this:

import * as crypto from 'crypto'

const salt = crypto.randomBytes(32).toString('hex');
const passphrase = 'password';

const decrypt = (passphrase, salt, text) => {
  const key = crypto.scryptSync(passphrase, salt, 32)
  const ivPosition = {
    start: 17,
    end: 17 + 32
  }

  const iv = Buffer.from(text.slice(ivPosition.start, ivPosition.end), 'hex')
  const part1: string = text.slice(0, ivPosition.start)
  const part2: string = text.slice(ivPosition.end)

  const encryptedText = `${part1}${part2}`

  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
  let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
  decrypted += decipher.final('utf8')

  return decrypted
}

Enter fullscreen mode Exit fullscreen mode

Creating Asymmetric Keys

Asymmetric keys, or public key cryptography, have transformed the way we protect sensitive information online. This guarantees extra security, although it is slower and requires more resources.

NodeJS supports a variety of asymmetric key types to meet diverse encryption needs. These include:

  • RSA: A widely used algorithm for asymmetric encryption, known for its security and efficiency;
  • DSA: Digital Signature Algorithm, often used for digital signatures and authentication;
  • EC (Elliptic Curve): Elliptic curve encryption algorithm, which offers a high level of security with smaller keys compared to RSA;
  • ed25519: A high-performance digital signature system based on elliptic curves;
  • ed448: Similar to ed25519, but with a higher level of security due to the larger key size;
  • x25519 and x448: Key exchange protocols based on elliptic curves, offering security and efficiency in restricted environments.

This variety of options allows developers to choose the algorithm best suited to their specific needs, balancing security, performance, and efficiency.

For this article, we will use the implementation of the RSA algorithm, given its wide use. In the initial stage, it is crucial to generate the asymmetric keys: the Public Key and the Private Key. To do this, it is essential to specify the types and formats of these keys. We will opt for the PEM (Privacy-Enhanced Mail) format for both keys, thus ensuring a consistent and secure approach.

generateKeyPair(passphrase: string): KeyPair {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 4096,
    publicKeyEncoding: {
      type: 'spki',
      format: 'pem'
    },
    privateKeyEncoding: {
      type: 'pkcs8',
      format: 'pem',
      cipher: 'aes-256-cbc',
            passphrase: passphrase 
    }
  })

  return {
    publicKey,
    privateKey
  }
}

Enter fullscreen mode Exit fullscreen mode

Firstly, it is important to understand the generateKeyPairSync function. It is responsible for generating a pair of keys: one public and one private.

When generating these keys, some parameters are essential:

  • modulusLength: This parameter defines the length of the module. In the case of RSA, the minimum length is 4096;
  • publicKeyEncoding: This object establishes the type and format of the public key. The type is defined as spki (Subject Public Key Info), a standard format for public keys. The format is pem, which is a base64 encoding type;
  • privateKeyEncoding: This object defines the type, format, cipher, and passphrase of the private key. The type is defined as pkcs8, also a standard format for private keys. The format is pem, similar to the public key. The cipher is defined as aes-256-cbc, an encryption algorithm as we saw earlier. The secret phrase, our passphrase, is the password used to encrypt the private key.

In NodeJS, there are two types of public key (pkcs1 and spki) and two types of private key (pkcs1 and pkcs8) when using RSA.

Here, we chose the spki/pkcs8 format. To add an extra layer of security, we also use the AES-256-CBC (Cipher Block Chaining) algorithm as mentioned previously.

As a result, the generateKeyPairSync function returns an object containing the generated public and private keys.

Differences between PKCS1 and PKCS8

PKCS is the acronym for "Public Key Cryptography Standards", which are a set of standards developed to assist the implementation of public key cryptography. Among these standards, we have PKCS1 and PKCS8, which describe formats for encoding private keys.

PKCS1 is the first PKCS standard, used specifically for encoding RSA keys. It details the process of encoding an RSA private key, which is relatively simple as an RSA key only requires a few integers.

In contrast, PKCS8 is a more comprehensive standard used to encode any type of private key. It can be used not only for RSA keys but also for other types of keys such as DSA or EC (Elliptic Curve). Thus, PKCS8 is a more general format compared to PKCS1 as it includes information about the key type in the private key data structure.

In short, the difference between PKCS1 and PKCS8 is that PKCS1 is specific to RSA keys, while PKCS8 can be used to encode any type of private key.

Encrypting with Asymmetric Keys

With our Public Key at your disposal, it is very easy to encrypt any message.

  1. First, we create a function that requires two parameters: text and publicKey;
  2. Next, we use the publicEncrypt function, which will be used to encrypt the text;
  3. After that, we use Buffer.from(text, 'utf8') to convert the text into a UTF-8 encoded buffer;
  4. Finally, we use toString('base64') to convert the buffer to base64.
const encrypt = (text, publicKey) => {
  return publicEncrypt(publicKey, Buffer.from(text, 'utf8')).toString('base64');
}

Enter fullscreen mode Exit fullscreen mode

With this, we have our encrypted message ready to be stored in a database or something similar.

Decrypting with asymmetric keys

To decrypt content, we only need our Private Key and the passphrase specified when creating the asymmetric keys.

  1. First, we create a function that requires two parameters: text and PrivateKey;
  2. Next, we use the privateDecrypt function, which will be used to decrypt the text;
  3. We pass the PrivateKey and Passphrase to the function;
  4. After that, we use Buffer.from(text, 'base64') to convert the text to a base64 buffer;
  5. Lastly we use .toString('utf8') to convert all content to utf8
const decrypt = (encryptedText, privateKey) => {
  return privateDecrypt({
    key: privateKey,
    passphrase
  }, Buffer.from(encryptedText, 'base64')).toString('utf8');
}

Enter fullscreen mode Exit fullscreen mode

After these steps, we have our original message back. With this, we complete the full cycle of encryption and decryption using asymmetric keys.

In conclusion, encryption plays a crucial role in protecting sensitive data and information in today's digital era.

I hope that, through this article, you have gained a deeper understanding of the differences and applications between symmetric and asymmetric encryption. Furthermore, with the code examples provided, you should be able to implement your own encryption and decryption functions in NodeJS.

Remember, data security is an important responsibility and encryption is one of the most effective tools we can use to ensure that security.

Top comments (0)