Easy Encryption In Typescript

vapourisation

Thomas Pegler

Posted on August 6, 2023

Easy Encryption In Typescript

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
Enter fullscreen mode Exit fullscreen mode

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
. . .
Enter fullscreen mode Exit fullscreen mode

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 );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 );
Enter fullscreen mode Exit fullscreen mode

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 );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
vapourisation
Thomas Pegler

Posted on August 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Easy Encryption In Typescript
typescript Easy Encryption In Typescript

August 6, 2023