TL;DR: NodeJS Crypto should provide access to
ChaCha20-Poly1305
which is a fantastic AEAD Encryption method which should cover the vast majority of use-cases while being fast, modern and strong enough for the foreseeable future.
Cryptography is hard. Good cryptography is even harder. Thankfully, most modern backend services provide many means for making this much easier for modern developers and NodeJS is no different.
When I started diving into NodeJS, trying to find good guides for cryptography in NodeJS was hard. There are a few blogs, some posts but nothing too specific and nothing which provides answers as to why some method is better than others. So I decided to read some books on cryptography and read through some NodeJS documentation.
Basic Encryption With NodeJS
NodeJS provides some useful cryptographic utilities in its crypto
module, it allows developers to utilise any algorithms and methods that are available in the locally installed OpenSSL module which, if installed, can be found using the below command on most systems:
openssl list -cipher-algorithms
Which, if openssl is installed, should give you a list that looks like like this:
AES-128-CBC
AES-128-CBC-HMAC-SHA1
AES-128-CBC-HMAC-SHA256
AES-128-CFB
AES-128-CFB1
AES-128-CFB8
AES-128-CTR
AES-128-ECB
AES-128-OCB
AES-128-OFB
AES-128-XTS
AES-192-CBC
AES-192-CFB
AES-192-CFB1
AES-192-CFB8
AES-192-CTR
AES-192-ECB
AES-192-OCB
AES-192-OFB
AES-256-CBC
AES-256-CBC-HMAC-SHA1
AES-256-CBC-HMAC-SHA256
. . .
For the majority of people not needing any serious cryptographic security but just looking to secure an application by encrypting some values, aes-256-cbc
will be enough, for some semblance of message authentication, using aes-256-cbc-hmac-sha256
will work fine. So let's see what that would look like in Typescript.
AES-256-CBC
import * as crypto from 'crypto';
function splitEncryptedText( encryptedText: string ) {
return {
ivString: encryptedText.slice( 0, 32 ),
encryptedDataString: encryptedText.slice( 32 ),
}
}
export default class Security {
encoding: BufferEncoding = 'hex';
// process.env.CRYPTO_KEY should be a 32 BYTE key
key: string = process.env.CRYPTO_KEY;
encrypt( plaintext: string ) {
try {
const iv = crypto.randomBytes( 16 );
const cipher = crypto.createCipheriv( 'aes-256-cbc', this.key, iv );
const encrypted = Buffer.concat( [
cipher.update(
plaintext, 'utf-8'
),
cipher.final(),
] );
return iv.toString( this.encoding ) + encrypted.toString( this.encoding );
} catch (e) {
console.error( e );
}
};
decrypt( cipherText: string ) {
const {
encryptedDataString,
ivString,
} = splitEncryptedText( cipherText );
try {
const iv = Buffer.from( ivString, this.encoding );
const encryptedText = Buffer.from( encryptedDataString, this.encoding );
const decipher = crypto.createDecipheriv( 'aes-256-cbc', this.key, iv );
const decrypted = decipher.update( encryptedText );
return Buffer.concat( [ decrypted, decipher.final() ] ).toString();
} catch (e) {
console.error( e );
}
}
}
Let's walk through what's happening here.
First, inside the encrypt
method we instantiate an iv
, Initialisation Vector, this is used to as the first source for a XOR method in the Chain (CBC stands for Cipher Block Chaining) which encrypts the data. This should always be random, preferably cryptographically secure but doesn't need to be secret.
const iv = crypto.randomBytes( 16 );
Then, we create the encrypt cipher, specifying the algorithm, the key and the IV.
Next, we need to encrypt the text using the cipher.update()
method, then calling cipher.final()
to close the cipher and ensure no other changes can be made (any further attempts to interact with the cipher result in an error being thrown).
After that, we combine the IV
and the encrypted string, converting them both to a specified encoding, I've used hex
encoding but that's really not really important, as long as the same encoding is used when decrypting.
Speaking of decrypting, this is just the reverse of encrypting. We split the supplied ciphertext into the encrypted text and the IV, use the IV with a decipher and recover the original text.
As I said, it's remarkably simple and will work for the majority of cases. Now, it should be mentioned that AES-CBC is potentially vulnerable to a Padding Oracle Attack, using the above mentioned aes-256-cbc-hmac-sha256
mitigates this and is most similar to the next method in that is allows for authenticated messages, meaning any attempts to interfere with shouldn't work.
ChaCha20-Poly1305
And now for the main event, a more robust, modern and arguably more secure encryption method. It should be noted that the two methods aren't strictly comparable, plain CBC does not authenticate messages so differs greatly from ChaCha20-Poly1305
. That being said, implementing it is very simple and requires only a few line changes from the above example.
import * as crypto from 'crypto';
function splitEncryptedText( encryptedText: string ) {
return {
encryptedDataString: encryptedText.slice( 56, -32 ),
ivString: encryptedText.slice( 0, 24 ),
assocDataString: encryptedText.slice( 24, 56 ),
tagString: encryptedText.slice( -32 ),
}
}
export default class Security {
encoding: BufferEncoding = 'hex';
// process.env.CRYPTO_KEY should be a 32 BYTE key
key: string = process.env.CRYPTO_KEY;
encrypt( plaintext: string ) {
try {
const iv = crypto.randomBytes( 12 );
const assocData = crypto.randomBytes( 16 );
const cipher = crypto.createCipheriv( 'chacha20-poly1305', this.key, iv, {
authTagLength: 16,
} );
cipher.setAAD( assocData, { plaintextLength: Buffer.byteLength( plaintext ) } );
const encrypted = Buffer.concat( [
cipher.update(
plaintext, 'utf-8'
),
cipher.final(),
] );
const tag = cipher.getAuthTag();
return iv.toString( this.encoding ) + assocData.toString( this.encoding ) + encrypted.toString( this.encoding ) + tag.toString( this.encoding );
} catch (e) {
console.error( e );
}
};
decrypt( cipherText: string ) {
const {
encryptedDataString,
ivString,
assocDataString,
tagString,
} = splitEncryptedText( cipherText );
try {
const iv = Buffer.from( ivString, this.encoding );
const encryptedText = Buffer.from( encryptedDataString, this.encoding );
const tag = Buffer.from( tagString, this.encoding );
const decipher = crypto.createDecipheriv( 'chacha20-poly1305', this.key, iv, { authTagLength: 16 } );
decipher.setAAD( Buffer.from( assocDataString, this.encoding ), { plaintextLength: encryptedDataString.length } );
decipher.setAuthTag( Buffer.from( tag ) );
const decrypted = decipher.update( encryptedText );
return Buffer.concat( [ decrypted, decipher.final() ] ).toString();
} catch (e) {
console.error( e );
}
}
}
Potential issues with ChaCha20-Poly1305
You may be asking, if ChaCha20-Poly1305 is so much better than aes-256-cbc
even with hmac-sha256
, why even mention them at all? Well, that boils down to something simple. The IV (nonce) size, 96-bits.
While this is generally not a problem, it is smaller than the aes-cbc
method which means it is less secure in this aspect. There is a fix for this, XChaCha20-Poly1305
which introduces a 192-bit IV (nonce). Unfortunately, as of yet (30/04/2022), this hasn't made its way into OpenSSL, and thus, not into NodeJS crypto module. So for now, we're stuck with standard ChaCha20-Poly1305
without attempting to implement it ourselves.
Header by rc.xyz NFT gallery on Unsplash
Top comments (6)
welcome
an impressive "opening" of your series.
thanks for sharing
Thank you so much! I'm not a cryptographer at all, just someone who gets frustrated whenever I have to implement some security and can't find "the proper way" to do it.
Well deserved.
I see, thats interesting…
I want to say a BIG welcome to you; congratulations on your first post. Please keep it; I can assure you that your contribution to this community will pay off.
Thank you so much! The community has already been fantastic so far and I get to read so many fantastic articles by brilliant writers as well. It's like having your own personal "how to" guides on anything you could want, all in one place.
can only confirm your observation…
💯