Smoen

Challenges of Working With Cryptography APIs in NodeJS

One of the main reasons that lead to insecure NodeJS applications is insecure or bad usage of cryptography APIs. Developers who are not very familiar with such APIs and the underlying crypto concepts often struggle to choose secure configuration options or to even get their code up and running.
This article assumes readers are familiar with the following concepts:
  • (Cryptographic) key: A key is a piece of information that is used to encrypt and decrypt data and can either be stand-alone (for symmetric encryption, meaning both the encryption and decryption are performed with the same key) or part of a key-pair where the private key is used to encrypt data and the corresponding public key is used to decrypt this data (Read more).
  • Cipher: An algorithm that encrypts and decrypts data following a certain protocol.
Let's start with an example:
Matt is a developer for a mid-sized company and has not yet gained much experience with cryptography. He has learned some concepts and read about the pros and cons of certain algorithms for cryptography, but when it comes to applying them, he is still a beginner.
Now his project leader has assigned him a task that requires encryption. The task is something like this:
"We should keep every text message in the system confidential. Encrypt them for storage so we can decrypt them later when needed. We need this cryptography feature asap".
Matt starts with a Google search and reads some Stack Overflow posts that point him towards the Cipher object in the Crypto module. Most cipher algorithms use two pieces of information, namely a secret key and an initialization vector (iv).
Matt opens his editor and starts writing the following JS code:
const crypto = require('crypto');

const key = process.env.PRIVATE_KEY;
const iv = Buffer.alloc(8,0);
const algorithm = 'des';

const cipher = crypto.createCipheriv(algorithm, key, iv);

cipher.update('first data that came in');
cipher.update('second data that came in');

let encrypted = cipher.final();

// Somewhere else, later
const decrypter = crypto.createDecipheriv(algorithm, key, iv);

cipher.update(encrypted);

let decrypted = cipher.final();
In the first few lines, the key is retrieved from an environment variable, a buffer is created to serve as iv and the cipher algorithm is chosen. Next, the cipher object is created and then updated with data that should be encrypted.
The call on line 12 finalizes the encryption and stores the result in a variable. To decrypt this data, a decipher object is created using the same algorithm, key and iv. This decipher object is then updated with the encrypted data and again the decryption is finalized with the (once again) unencrypted data stored in a variable.
This will most certainly not run without error, but result in an 'invalid key length error'. Cipher algorithms that use a key to encrypt data require a key of a certain length, depending on which cipher algorithm was chosen.
After a bit of research, Matt finds out that the key must have the same length as the block length of the algorithm. Sometime later, he finds the scryptSync function that derives a key of a specific length from a password and a random salt.
He then adjusts his key and gets to this:
const key = crypto.scryptSync(process.env.PRIVATE_KEY, 'salt', 16);
Now the cipher will work. Matt stores the encrypted result and tests the decryption, which yields the following error:
error:06065064:digital envelope routines:EVP_DecryptFinal_ex:bad decrypt
An experienced user knows that the error occurs because he did not concatenate the results from the update calls. This led to the data being of a wrong length to be decrypted correctly. However, to the inexperienced Matt, this looks like gibberish and will give him a headache for some time.
Finally, Matt will find out that he has to concatenate all results from the update and the final call and adjusts his code accordingly:
const cipher = crypto.createCipheriv(algorithm, key, iv);

let encrypted = cipher.update('first part that came in');
encrypted += cipher.update('second part that came in');
encrypted += cipher.final();
 
// Somewhere else, later
const decrypter = crypto.createDecipheriv(algorithm, key, iv);
 
let decrypted = decrypter.update(encrypted);
 
decrypted += decrypter.final(); 
Unfortunately, Matt receives a new error:
error:0606506D:digital envelope routines:EVP_DecryptFinal_ex:wrong final block length
After doing some research he finds that by default the input on the update function gets treated as a buffer, but Matt is using strings. He then also realizes that he can set the encoding of the input and the desired output to tell NodeJS to both treat the input as a string and return a string with the given encoding.
After adjusting, the code finally works and looks like this:
const crypto = require('crypto');
 
const key = crypto.scryptSync(process.env.PRIVATE_KEY, 'salt', 8);
const iv = Buffer.alloc(8,0);
const algorithm = 'des';
 
const cipher = crypto.createCipheriv(algorithm, key, iv);
 
let encrypted = cipher.update('first part that came in', 'utf8', 'hex' );
encrypted += cipher.update('second part that came in' , 'utf8', 'hex');
encrypted += cipher.final('hex');
 
// Somewhere else, later
const decrypter = crypto.createDecipheriv(algorithm, key, iv);
 
let decrypted = decrypter.update(encrypted, 'hex', 'utf8');
 
decrypted += decrypter.final('utf8'); 
On line 3, the key is derived from an environment variable, a salt and the desired length. Then a buffer of length 16 bytes is allocated to be used as iv and the algorithm to be used for the encryption is specified. The cipher is then created and updated with the data that should be created. Since the encodings are set, the data inputs are treated as strings before and after the encryption.
After the final call Matt receives the encrypted data stored in a variable. Later, the decipher object is created and updated with the encrypted data. The encodings are then set again to ensure that the data is treated correctly. After the final call, Matt retrieves the decrypted data stored in a variable.
Finally, the cryptography feature seems to work, but is it secure?
The short answer is NO: the salt is in plain text and not random, the initalization vector is not random either, there are more secure algorithms than des, and so on. However, Matt has already spent too much time on solving the challenges that come with getting cryptographic code to work.
It would have been much easier if he could have just told the API that he wants to encrypt data and then decrypt it later, without having to search for a (secure) algorithm, without having to understand how long the key and the iv have to be, and with more useful error messages when something goes wrong.
In the next article we discuss how FluentCrypto will make this possible.

Tags

Topics of interest