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
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 );
}
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;
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 );
}
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();
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)