DEV Community

Cover image for Easy Encryption in Typescript 2: Libsodium Boogaloo
Thomas Pegler
Thomas Pegler

Posted on

Easy Encryption in Typescript 2: Libsodium Boogaloo

Preface: As always, I am not a cryptographer or mathematician. I'm a software engineer who is passionate about encryption and I strive to learn more about it every day but I will make mistakes. If you spot something in here that is incorrect, please let me know so we can help others avoid implementing bad cryptography. Thank you.

In part 1 I discussed the AEAD encryption method, ChaCha20-Poly1305. I also mentioned that, while good, it is not perfect due to the length of the IV (nonce). I also discussed XChaCha20, the improved version and today, I'll show you how you can use that in your Typescript projects using the popular encryption library, libsodium.

TL;DR. Show me the code

npm i libsodium-wrappers
npm i @types/libsodium-wrappers
Enter fullscreen mode Exit fullscreen mode
import _sodium from 'libsodium-wrappers';

await _sodium.ready;
const sodium = _sodium;

const password = process.env.CRYPTO_KEY;


/**
 * Utilises libsodium xChaCha-Poly1305 to encrypt a string.
 * @param {string} plaintext - Plaintext to be encrypted.
 * @returns {string}
 */
export function encrypt( plaintext: string ): string {
  const key = sodium.crypto_kdf_derive_from_key(32, 0, '', Buffer.from( password ) );
  const ad = sodium.randombytes_buf( sodium.crypto_secretstream_xchacha20poly1305_ABYTES );
  const res = sodium.crypto_secretstream_xchacha20poly1305_init_push( key );
  const [ stateOut, header ] = [ res.state, res.header ];
  const c1 = sodium.crypto_secretstream_xchacha20poly1305_push( stateOut,
    sodium.from_string( plaintext ),
    ad,
    sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE );

  const merged = new Uint8Array( header.length + ad.length + c1.length );
  merged.set( header );
  merged.set( ad, header.length );
  merged.set( c1, header.length + ad.length );

  return sodium.to_base64( merged );
}

/**
 * Utilises libsodium xChaCha-Poly1305 to decrypt a string.
 * @param {string} cipherText - Ciphertext to be decrypted.
 * @returns {string}
 */
export function decrypt( cipherText: string ): string {
  const cipher = sodium.from_base64( cipherText );

  const key = sodium.crypto_kdf_derive_from_key(32, 0, '', Buffer.from( password ) );
  const decodedHeader = cipher.slice( 0, sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES );
  const ad = cipher.slice(
    sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES,
    sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES + sodium.crypto_secretstream_xchacha20poly1305_ABYTES,
  );
  const decodedCipherText = cipher.slice( sodium.crypto_secretstream_xchacha20poly1305_ABYTES + sodium.crypto_secretstream_xchacha20poly1305_HEADERBYTES );

  const stateIn = sodium.crypto_secretstream_xchacha20poly1305_init_pull( decodedHeader, key );
  const r1 = sodium.crypto_secretstream_xchacha20poly1305_pull( stateIn, decodedCipherText, ad );
  return sodium.to_string( r1.message );
}
Enter fullscreen mode Exit fullscreen mode

With that little TL;DR out of the way, let's actually go through this. First, we need to set up libsodium and ensure it is running.

import _sodium from 'libsodium-wrappers';

await _sodium.ready;
const sodium = _sodium;
Enter fullscreen mode Exit fullscreen mode

Sodium provides a .ready property which is a promise that must resolve before the sodium functions can be used.

export function encrypt( plaintext: string ): string {
  const key = sodium.crypto_kdf_derive_from_key(32, 0, '', Buffer.from( password ) );
  const ad = sodium.randombytes_buf( sodium.crypto_secretstream_xchacha20poly1305_ABYTES );
  const res = sodium.crypto_secretstream_xchacha20poly1305_init_push( key );
  const [ stateOut, header ] = [ res.state, res.header ];
  const c1 = sodium.crypto_secretstream_xchacha20poly1305_push( stateOut,
    sodium.from_string( plaintext ),
    ad,
    sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE );

  const merged = new Uint8Array( header.length + ad.length + c1.length );
  merged.set( header );
  merged.set( ad, header.length );
  merged.set( c1, header.length + ad.length );

  return sodium.to_base64( merged );
}
Enter fullscreen mode Exit fullscreen mode

So we start by using a KDF to generate an encryption key from our password. You can also use libsodium to create a sort of "master key" instead of having a password:

const master = sodium.crypto_secretstream_xchacha20poly1305_keygen();
Enter fullscreen mode Exit fullscreen mode

If you decided to do so, it is important to store these keys securely.

Then, we need an AD, Associated Data (the AD part of AEAD), which we get as random bytes. These should always be random, every time. Do not reuse ADs.

Next, we initialise the State and get the Header (IV/Nonce). With the state initialised and IV returned, we can encrypt with the crypto_secretstream_xchacha20poly1305_push method. After that we simply combine the Header/IV, Associated Data and the encrypted data.

As always, decoding is just the reverse. We derive the key from our "master key". Then split the ciphertext into the Header/IV, AD and the encrypted data. We initialise the state again with the derived key, then we decrypt with the crypto_secretstream_xchacha20poly1305_pull method.

Final thoughts

This process is a little more involved than the previous non-X ChaCha implementation but it not only uses a fantastic, widely supported library in Libsodium, it also ensures an encryption method more secure again nonce-reuse with the larger (192-bit) nonce size.


Header by Brock Wegner on Unsplash

Top comments (0)