tutorial // Sep 09, 2022
How to Encrypt and Decrypt Text with Node.js
How to use the Node.js crypto library to write a function for encrypting and decrypting text using AES-256 encryption.
For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.
To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):
Terminal
npm i -g @joystick.js/cli
This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:
Terminal
joystick create app
After a few seconds, you will see a message logged out to cd
into your new project and run joystick start
:
Terminal
cd app && joystick start
After this, your app should be running and we're ready to get started.
Writing an encryption function
To begin, we need to wire up a function that will take in some text as a string (plaintext) and encrypt it into another form of string known as ciphertext. In order to create that ciphertext, we need the plain text (data) we want to encrypt and an encryption key that can be used to encrypt the data in a way that can only be reversed with that encryption key.
Inside of the app we just created via joystick create app
, we want to create a file in the /lib
folder that was generated for us, encryptText.js
, in a new folder /lib/node
. The /node
part is important as the crypto
library we'll use below will only work in a Node.js environment. The framework we're using, Joystick, utilizes the /lib/node
folder to isolate miscellaneous (or "lib(rary)") code that can only run in a Node.js environment.
/lib/node/encryptText.js
import crypto from 'crypto';
export default (data = '', encryptionKey = '') => {
// We'll implement the encryption here...
}
Above, we create a skeleton for our function, exporting as the default value from /lib/node/encrypText.js
. To that function, we'll anticipate two arguments: data
, which will be the text we want to encrypt as a string and encryptionKey
, the secret "password" we want to use to encrypt the text. Up at the top, we've preemptively imported the crypto
library that's built-in to Node.js as crypto
(here, "crypto" is short for "cryptography"—not to be confused with cryptocurrency).
/lib/node/encryptText.js
import crypto from 'crypto';
export default (data = '', encryptionKey = '') => {
const initializationVector = crypto.randomBytes(16);
const hashedEncryptionKey = crypto.createHash('sha256').update(encryptionKey).digest('hex').substring(0, 32);
// We'll handle the actual encryption here...
};
In order to complete the encryption process, we need two things:
- An
initializationVector
that's exactly 16 bytes (characters) in length containing a random value that can be used to initialize the state of the cipher algorithm (the technical name for the code that will actually encrypt our data). This helps to guarantee that the resulting ciphertexts of the same input value/data are not identical (in the event that they were, a hypothetical attacker can use repeating patterns to interpret the plaintext contents of the ciphertext—though highly unlikely, we want to eliminate the possibility just in case). That exact length (16 bytes) is dictated by the cipher algorithm we use which in this case is AES-256. - An
encryptionKey
that's exactly 32 bytes (characters) in length. That exact length is dictated by the cipher algorithm we use which in this case is AES-256.
For our initialization vector, we can utilize the crypto.randomBytes()
method, passing in the number of bytes we want the return value to contain (in this case, 16).
For our encryption key, because we need to guarantee our encryption key is 32 bytes (again, enforced by the cipher algorithm we'll be using, AES-256), we have two options:
- Manually ensure that the
encryptionKey
we pass to our function is 32 characters or bytes long. - Allow an
encryptionKey
of any length to be passed and rely on a hashing algorithm to generate a value (hash) of a consistent length.
Here, we're opting for number two to keep our code stable. To perform the hashing, we rely on the crypto.createHash()
method to create what's known as a "hash object." To that method, we pass the hashing algorithm to use (here, we're using SHA-256
—not to be confused with the cipher algorithm we'll use AES-256
).
On the object returned by crypto.createHash()
, we're given an .update()
method that's called passing in the data we want to hash. In return to that function, we get another method .digest()
which returns the resulting hashed data as a Buffer. For our needs, instead of a Buffer, we just want a string. To get it, we pass 'hex'
to .digest()
to get back the resulting hashed data as a 64-character hexadecimal string (this length is consistent no matter the original input). Because we need exactly 32 characters, we use the JavaScript .substring()
method passing 0, 32
to say "give us all characters from the first character up to the 32nd character." Here, 0
is used instead of 1
as .substring()
is zero-based.
Now, this gets us a hash of our encryption key but...what in the hell is going on here?
In cryptography, a "hash" is a mathematically scrambled piece of text (or, "string" if you prefer) that cannot be reversed. It serves as a means for enhancing the security of things like passwords or other sensitive information. Here, we're using the SHA-256 hashing algorithm to create a hash of our encryption key. While we technically don't have to do this (assuming the encryption key we pass is 32 characters long), it's a bulletproof way to ensure we don't make a mistake and break our encryptText()
function by passing an encryption key that's too short or too long.
This is because a SHA-256 hash will always produce an output of a predictable length, no matter the length of our input. So, if we have an input encryption key of "1234" or "homersimpson," the resulting hash as a hexadecimal value will always be 64 characters long. Here, because the AES-256 algorithm we'll look at next requires a 32 character encryption key, we just take the first 32 characters of the 64 character hex string (which we can trust will always exist due to the consistent length of the hash).
/lib/node/encryptText.js
import crypto from 'crypto';
export default (data = '', encryptionKey = '') => {
const initializationVector = crypto.randomBytes(16);
const hashedEncryptionKey = crypto.createHash('sha256').update(encryptionKey).digest('hex').substring(0, 32);
const cipher = crypto.createCipheriv('aes256', hashedEncryptionKey, initializationVector);
let encryptedData = cipher.update(Buffer.from(data, 'utf-8'));
encryptedData = Buffer.concat([encryptedData, cipher.final()]);
return `${initializationVector.toString('hex')}:${encryptedData.toString('hex')}`;
};
Now for the fun part. In order to encrypt our data, we first need to create a cipher object. To do it, we're going to use the crypto.createCipheriv()
method from the crypto library (this is the successor to the now deprecated crypto.createCipher()
function which did not require an initialization vector). To that function, we pass the name of the cipher algorithm we want to use (here, AES-256 which is formatted as aes256
as expected by Node.js), our hashedEncryptionKey
, and our initializationVector
.
In return, we get our cipher
object which can be used to encrypt our data. First, we need to "load in" the data we want to encrypt into the cipher object which is done via the cipher.update()
method, passing in the data to encrypt. While we can technically get away with passing our data
directly to cipher.update()
, to be safe, we first convert it to a Buffer here with Buffer.from()
passing the exact encoding of our data (in this case, a string in UTF-8
format). In response we get the start of a Buffer which represents our encryptedData
. Here, we create a let
variable (meaning it's mutable, or, we can overwrite it later) to store that "first part."
What is a buffer? - A buffer is a convention for storing and referencing binary data in memory in Node.js. An excellent explanation of how this works is available here.
Next, in order to actually perform the encryption and get our encrypted text (ciphertext) we need to call the cipher.final()
method on our cipher
object which returns our ciphertext as a Buffer. This is the "second part" of the Buffer that completes our encrypted data. Notice that here, we're overwriting the variable encryptedData
with the result of calling Buffer.concat()
, passing an array with the original encryptedData
buffer as the first value (the "first part") and the result of calling cipher.final()
(the "second part") as the second value. Buffer.concat()
—like the name implies—concatenates or "joins together" two or more buffers into one.
To make this useful to us, at the bottom of our function, we create a new string using backticks to allow us to use JavaScript string interpolation. Inside of those backticks, we concatenate (join together) the result of our initializationVector
converted to a hexadecimal string with our encryptedData
as a hexadecimal string, separated by a :
colon.
The idea here is that because our initializationVector
is random and required to decrypt our data, we prefix it to our encryptedData
so we can have access to it at decryption time (joining them together gives us a single value to keep track of vs. two separate ones which can lead to mistakes). The :
colon character is just a convenient "marker" that we can use in our decryption function next to separate the initializationVector
from the encryptedData
.
That does it for encryption. Next, let's take a look at our decryptText
function. We'll be reusing a lot of the same concepts, just in reverse.
Writing a decryption function
Following the same pattern, now, we want to create another function in a separate file at /lib/node/decryptText.js
(remember, the crpyto
library we're using only works in a Node.js environment—to avoid errors, we store this file in the /lib/node
folder of our app). For this one, we're going to show the entire function and step through it as a lot of the concepts are similar.
/lib/node/decryptText.js
import crypto from 'crypto';
export default (encryptedData = '', encryptionKey = '') => {
const [initializationVectorAsHex, encryptedDataAsHex] = encryptedData?.split(':');
const initializationVector = Buffer.from(initializationVectorAsHex, 'hex');
const hashedEncryptionKey = crypto.createHash('sha256').update(encryptionKey).digest('hex').substring(0, 32);
const decipher = crypto.createDecipheriv('aes256', hashedEncryptionKey, initializationVector);
let decryptedText = decipher.update(Buffer.from(encryptedDataAsHex, 'hex'));
decryptedText = Buffer.concat([decryptedText, decipher.final()]);
return decryptedText.toString();
};
Remember: our goal here is to reverse the encryption we just implemented. To get started, first, remember that when we returned our encrypted text (ciphertext) from encryptText()
, we concatenated (joined together) the initializationVector
as a hexadecimal value with our encryptedData
as a hexadecimal value.
Just inside of our decryptText()
function here, we begin by running a JavaScript .split()
on our encryptedData
string (we assume this is a value we retrieved at some point in the past from encryptText()
), splitting on the :
character. This will split the string into an array of strings, with each string in that array containing all of the text before each :
colon. For example, if we passed homer:simpson
, this would give us ['homer', 'simpson']
and if we passed abc1234:000999:def456
we'd get ['abc1234', '000999', 'def456']
.
For our needs, we can assume that we'll only have two values in the result array, so, here, we use a technique known as JavaScript Array Destructuring. This allows us to assign variable names to array indexes automatically. Here, we know that we'll only have two values in our array so we can use array destructuring to identify those values.
First, we expect the initializationVector
as a hexadecimal string to be at index 0 (first) in the array, so we can label that value as initializationVectorAsHex
. Similarly, we expect our encrypted data to be at index 1 (second) in the array, so we can label that value encryptedDataAsHex
. What's cool about this is that now, these values are stored in their respective variables and can be referenced via those names in the code below.
Putting initializationVectorAsHex
to immediate use, we begin by converting the hexadecimal string back into a Buffer. To do it, we use Buffer.from()
, passing in the hexadecimal value as the first argument and then identifying the encoding of that value hex
as the second argument (this tells the Buffer.from()
function how to interpret the data).
Next, identical to what we saw above, we need to take the plaintext encryptionKey
that we used to encrypt our data and hash it as a hexadecimal value, getting the first 32 characters. Remember: this works because given an identical input, a hashing algorithm will always output the same hash.
From here, everything is the same as what we saw earlier with two big changes:
-
Instead of using
crypto.createCipheriv()
, we usecrypto.createDecipheriv()
(as the name implies, we want to reverse the work done by the original cipher algorithm). -
We're using the name
decipher
instead ofcipher
for our variables. The methods called on this variable.update()
and.final()
follow the exact same logic as theircipher.update()
andcipher.final()
equivalents. The usage ofBuffer.concat()
to concatenate the "first part" of the buffer fromdecipher.update()
and the "second part" fromdecipher.final()
behaves the same way as well.
The big difference here is the return value at the bottom of our function. Because we're decrypting a previously encrypted string, all we need to do at the bottom is return our decryptedText()
. Just like we saw with cipher.final()
, decipher.final()
here returns a Buffer. To make that useful for us, we convert it to a string by calling the Buffer's .toString()
method.
That's it! Next, let's test this out and make sure everything is working.
Testing encrypt/decrypt
To test this out, we're going to wire up some quick and dirty code in the /index.server.js
file in the app created for us at the start of this tutorial. This file contains the code that starts up the HTTP server for our app (via Express.js). We're going to chain a callback function onto that code and run our encryptText()
and decryptText()
functions inside of that callback function.
/index.server.js
import node from "@joystick.js/node";
import api from "./api";
import encryptText from "./lib/node/encryptText";
import decryptText from "./lib/node/decryptText";
node.app({
api,
routes: { ... },
}).then(() => {
const encryptedText = encryptText(
'this is the secret information that must be hidden',
'thematrixwasadocumentary'
);
console.log({
encryptedText,
decryptedText: decryptText(encryptedText, 'thematrixwasadocumentary'),
});
});
Here, the node.app()
function already being called in the file returns a JavaScript Promise. This means that we can "chain" a callback onto the end of it via its provided .then()
function. Here, we do that, calling .then()
on node.app()
and passing the callback function to run after node.app()
has completed its work (starting up our HTTP/Express server).
Inside, we call to our encryptText()
function passing the data we'd like to encrypt ('this is the secret information that must be hidden'
) along with an encryption key ('thematrixwasadocumentary'
), storing the result in a variable const encryptedText
.
Next, we perform a console.log()
passing an object with two properties: the encryptedText
(so we can see how it looks) and then, the result of calling decryptText()
, passing in our encryptedText
and encryption key. If we save our file and take a look at the terminal/console where we started our app up, we should see something like this printed out:
Terminal
{
encryptedText: '97d1695b00d9a27eb7eea9c07583ed67:f1e252d0928a2554c54bc61ef8997d8d85c257a4aa2aadd5ef8729539f6d42120d7b5303a47cb787107beb27048e16254ee918cd69f7a5c97987e62313bb1ab2',
decryptedText: 'this is the secret information that must be hidden'
}
Wrapping up
In this tutorial, we learned how to encrypt and decrypt text in Node.js. We learned how to wire up an encrypt function, learning how to create a SHA-256 hash of our encryption key and then encrypt some text using the AES-256 cipher algorithm via the crypto.createCipheriv()
function built-in to Node.js. Next, we learned how to decrypt our encrypted data, again using a SHA-256 hash of our encryption key and the AES-256 cipher algorithm via the crypto.createDecipheriv()
function built-in to Node.js.