We have a Python application that stores strings as encrypted binary data in MongoDB, it uses
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
On the NodeJS side I've been having trouble figuring out how to decrypt the data, I have our salt, our key, but as far as I can tell there's no IV, or the python module may just be hiding all of that under the hood as all the python application has to do is call encrypt(value, salt) and decrypt(value, salt)
Python:
class ChaChaEncryptedStringField(EncryptedStringField):
"""
A field which, given an encryption key and salt, will automatically encrypt/decrypt
sensitive data to avoid needing to do this before passing in. This encryption
method reliably produces a searchable string.
"""
def __init__(self, key, salt, *args, **kwargs):
"""Initialize the ChaChaEncryptedStringField.
Args:
key (str) -
salt (str) -
"""
class Hook:
def __init__(self, key, salt):
self.salt = salt
self.chacha = ChaCha20Poly1305(key)
def encrypt(self, value):
return self.chacha.encrypt(self.salt, value, None)
def decrypt(self, value):
return self.chacha.decrypt(self.salt, value, None)
self.encryption_hook = Hook(b64decode(key), b64decode(salt))
super(EncryptedStringField, self).__init__(*args, **kwargs)
Javascript (that isn't working but close):
const authTagLocation = data.buffer.length - 16;
const ivLocation = data.buffer.length - 28;
const authTag = data.buffer.slice(authTagLocation);
const iv = data.buffer.slice(ivLocation, authTagLocation);
const encrypted = data.buffer.slice(0, ivLocation);
const decipher = crypto.createDecipheriv('chacha20-poly1305', keyBuffer, iv,{ authTagLength: 16 } );
let dec = decipher.update(
data.buffer, 'utf-8', 'utf-8'
);
dec += decipher.final('utf-8');
return dec.toString();
With some research and trial and error I got past it complaining about improper IV, and key length is correct, but still getting garbled data back out
So I actually got the following code to work, but I'm not going to claim to fully understand what is happening:
working Javascript (salt is pulled from secrets, using the IV extracted fails)
const authTagLength = 16
const authTagLocation = data.buffer.length - authTagLength;
const ivLocation = data.buffer.length - 16;
const authTag = data.buffer.slice(authTagLocation);
const iv = data.buffer.slice(ivLocation, authTagLocation);
const encrypted = data.buffer.slice(0, ivLocation);
const decipher = crypto.createDecipheriv('chacha20-poly1305', keyBuffer, saltBuffer,{ authTagLength: authTagLength } );
let dec = decipher.update(
encrypted, 'utf-8', 'utf-8'
);
dec += decipher.final('utf-8');
return dec.toString();
What is called salt in the Python code is actually the nonce (or IV), see the Cryptography documentation for ChaCha20Poly1305
). The difference between nonce and salt is explained e.g. here. In the following I use the term nonce.
In the NodeJS code, the separation of ciphertext and tag is performed in a way that is far too complicated, but (coincidentally) results in the correct result. The IV does not play a role in the separation. The tag is the last 16 bytes, the actual ciphertext is the remaining data before the tag.
Also, currently no authentication takes place, which is insecure. To enable authentication, the tag must be set with setAuthTag()
before the final()
call. If the authentication fails, an exception is thrown.
The following example shows a possible NodeJS implementation for decryption. The ciphertext was generated with the posted Python code:
const crypto = require('crypto');
const keyBuffer = Buffer.from('MDEyMzQ1Njc4OTAxMjM0NTAxMjM0NTY3ODkwMTIzNDU=', 'base64');
const nonceBuffer = Buffer.from('MDEyMzQ1Njc4OTAx', 'base64')
const dataBuffer = Buffer.from('4bAaXOlQGhLI3tAsJju0e8Z737eF683Izik+6Uz4axPKj6NbmGLXcCgxukIyo8whOsu2lEgg3llInLA=', 'base64')
const authTagLength = 16
const encrypted = dataBuffer.slice(0, -authTagLength)
const tag = dataBuffer.slice(-authTagLength);
const decipher = crypto.createDecipheriv('chacha20-poly1305', keyBuffer, nonceBuffer, {authTagLength: authTagLength});
decipher.setAuthTag(tag)
let decrypted;
try {
decrypted = decipher.update(encrypted, '', 'utf-8');
decrypted += decipher.final('utf-8');
console.log(decrypted);
} catch(e) {
console.log("Decryption failed!");
}
Note the following vulnerability in the Python code: Key and nonce are passed to the ChaChaEncryptedStringField
class upon instantiation. This results in the same key/IV pair being used for all encryptions performed with this instance, which is insecure, see here. The correct approach is to create a random nonce for each encryption. The nonce is not secret and is passed along with the ciphertext and tag, typically concatenated.