javascriptc++node.jsopensslcryptography

How to decrypt C++ OpenSSL ChaCha20 from nodeJS?


I'm trying to decrypt data that have been encrypted using C++ OpenSSL ChaCha20 from Node.js, but standard Node.js crypto module doesn't directly support ChaCha20 without Poly1305.

I have tried using the JsChaCha20 lib, it doesnt fail but the decrypted data is not correctly, the decrypt function return this: 'bAۄ���@��'

I'm trying to figure if its a problem with my code or with the JsChaCha20 lib or even a missmatch between the OpenSSL implementation and the lib.

// https://github.com/thesimj/js-chacha20/blob/master/src/jschacha20.js
import crypto     from 'crypto'
import JSChaCha20 from './jschacha20.js'

function decrypt(data, password)
{
    if (!data.length)
        return false

    // Generate a salt from the password
    const pwhash = crypto.createHash('sha256').update(password).digest()
    const salt   = Buffer.from(pwhash)

    const NONCE_SIZE = 12
    const KEY_SIZE   = 32

    // Derive key from password
    const key = crypto.createHash('sha256').update(password).digest()

    // Derive nonce
    const context = Buffer.concat([Buffer.from(password), salt])
    const hash    = crypto.createHash('sha256').update(context).digest()
    const nonce   = hash.slice(0, NONCE_SIZE)

    // Log values for comparison
    console.log('\nkey\n', key.toString('base64'))
    console.log('\nnonce\n', nonce.toString('base64'))
    console.log('\nsalt\n', salt.toString('base64'))
    console.log('\ncontext\n', context.toString('base64'))

    try 
    {
        const decipher = new JSChaCha20(key, nonce)
        let decrypted  = decipher.decrypt(Buffer.from(data))
        decrypted      = Buffer.from(decrypted).toString('utf8')
        return decrypted
    } 
    catch (error)
    {
        console.error(error.message)
        return false
    }
}

let encrypted   = "b1OnS61xyC/D0Dc="
encrypted       = Buffer.from(encrypted, 'base64')
const decrypted = decrypt(encrypted, "password")

My C++ OpenSSL ChaCha20 implementation:

#include <openssl/sha.h>
#include <openssl/buffer.h>
#include <openssl/rand.h>
#include <openssl/evp.h>

bool encrypt(std::string& data, std::string& password)
{
    if (data.empty()) 
        return false;
    
    // Generate a salt from the password
    unsigned char pwhash[SHA256_DIGEST_LENGTH];
    SHA256(reinterpret_cast<const unsigned char*>(password.data()), password.size(), pwhash);
    std::vector<unsigned char> salt(pwhash, pwhash + 32);

    constexpr size_t NONCE_SIZE = 12;
    constexpr size_t KEY_SIZE = 32;

    // Derive key from password
    unsigned char key[KEY_SIZE];
    SHA256(reinterpret_cast<const unsigned char*>(password.data()), password.size(), key);

    // Derive nonce
    std::vector<unsigned char> context;
    context.reserve(password.size() + salt.size());
    context.insert(context.end(), password.begin(), password.end());
    context.insert(context.end(), salt.begin(), salt.end());
    
    unsigned char hash[SHA256_DIGEST_LENGTH];
    SHA256(context.data(), context.size(), hash);
    std::vector<unsigned char> nonce(hash, hash + NONCE_SIZE);
    
    // Encrypt with derived nonce
    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
    if (!ctx) return false;
    if (EVP_EncryptInit_ex(ctx, EVP_chacha20(), nullptr, key, nonce.data()) != 1) {
        EVP_CIPHER_CTX_free(ctx);
        return false;
    }

    std::vector<char> ciphertext(data.size(), 0);
    int ciphertextLen;
    if (EVP_EncryptUpdate(ctx, reinterpret_cast<unsigned char*>(ciphertext.data()), &ciphertextLen, 
                          reinterpret_cast<const unsigned char*>(data.data()), data.size()) != 1) {
        EVP_CIPHER_CTX_free(ctx);
        return false;
    }
    EVP_CIPHER_CTX_free(ctx);
    ciphertext.resize(ciphertextLen);

    // Print key, nonce, salt, and context in base64
    auto toBase64 = [](const unsigned char* data, size_t len) -> std::string {
        BIO* b64 = BIO_new(BIO_f_base64());
        BIO* bio = BIO_new(BIO_s_mem());
        bio = BIO_push(b64, bio);
        BIO_write(bio, data, len);
        BIO_flush(bio);
        BUF_MEM* bufferPtr;
        BIO_get_mem_ptr(bio, &bufferPtr);
        std::string result(bufferPtr->data, bufferPtr->length - 1); // Exclude the null terminator
        BIO_free_all(bio);
        return result;
    };

    std::cout << "\nkey\n"     << toBase64(key, sizeof(key));
    std::cout << "\nnonce\n"   << toBase64(nonce.data(), nonce.size());
    std::cout << "\nsalt\n"    << toBase64(salt.data(), salt.size());
    std::cout << "\ncontext\n" << toBase64(context.data(), context.size());

    int encDataSize = 4 * ((data.length() + 2) / 3);
    std::vector<unsigned char> b64(encDataSize + 1); // +1 for the terminating null that EVP_EncodeBlock adds on
    EVP_EncodeBlock(b64.data(), reinterpret_cast<const unsigned char*>(ciphertext.data()), ciphertext.size());

    data = std::string(b64.begin(), b64.end());

    return true;
}


int main()
{
    std::string data     = "Hello World";
    std::string password = "password";
    encrypt(data, password);
}

key, nonce, salt, contenxt all them matches the values from the C++ function.

Im derivating the nonce from the password, so i wouldnt need to append it to the cipher, i know this is not secure, but im trying to not increase the data size, this is why im trying ChaCha20 over Poly1305 as Poly1305 also appends a tag.


Solution

  • You are not using the nonce for EVP_chacha20() appropriately. From the documentation:

    EVP_chacha20()

    The ChaCha20 stream cipher. The key length is 256 bits, the IV is 128 bits long. The first 32 bits consists of a counter in little-endian order followed by a 96 bit nonce. For example a nonce of:

    000000000000000000000002

    With an initial counter of 42 (2a in hex) would be expressed as:

    2a000000000000000000000000000002

    Your nonce corresponds to the IV, which should be 16 bytes long according to the description. You are using 12 bytes. This is implicitly padded with 0xFD (although it is unclear to me why 0xFD is used). The first 4 bytes of the resulting IV are used as counter in little endian order, the rest is the actual nonce.

    The JavaScript library allows the separate specification of nonce and counter. This requires the following change:

    ...
    const iv = Buffer.concat([nonce, Buffer.from('fdfdfdfd', 'hex')])
    const ctr = iv.subarray(0, 4).readUInt32LE(0)
    const actNonce = iv.subarray(4)
    
    try 
    {
        const decipher = new JSChaCha20(key, actNonce, ctr)
        ...
    

    With this, the decryption is successful.


    Note that the C code should also be fixed. There a 16 bytes IV should be applied, which is to be interpreted as concatenation of the 4 bytes (little endian) counter and the 12 bytes nonce. These values should then be used accordingly in the JavaScript code.
    The above fix also applies for a 16 bytes IV, but with const iv = nonce as there is no padding (and of course const NONCE_SIZE = 16).