Easy Encryption In Typescript
Tags: typescript, security, node, tutorial
Published: 2023-08-06 09:46:29

> ***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:

```bash
openssl list -cipher-algorithms
```

Which, if openssl is installed, should give you a list that looks like like this:

```bash
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

```typescript
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.

```typescript
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,](https://en.wikipedia.org/wiki/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.

```typescript
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 <a href="https://unsplash.com/@moneyphotos?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">rc.xyz NFT gallery</a> on <a href="https://unsplash.com/photos/eNxYF6cexYU?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>