Equivalent AES-256 Encryption in Rust (Migrating from Node.js crypto.createCipher)

I want to migrating an encryption function from Node.js to Rust and need guidance on ensuring the Rust implementation behaves the same way as the Node.js crypto.createCipher method. I want to make sure that encryption and decryption are interoperable between the two languages.

Node Code :

const crypto = require('crypto');

var encryption = function (doc) {
    const cipher = crypto.createCipher('aes256', 'QLFZQhdUmCgQa5cSBYlHFeqGYw0yPtWiqvCbdYIlQnq3jDMSaWnjR0FjeeyIU8rd');
    let encrypted = cipher.update(doc, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return encrypted;
};

Questions & Concerns:

  1. What is the best way to implement the same AES-256 encryption in Rust?
  2. Does crypto.createCipher('aes256', key) apply any implicit padding?

I tried implementing AES-256 decryption in Rust using the aes crate, but I get a key length mismatch error:

use aes::Aes256;
use aes::cipher::{KeyInit, generic_array::GenericArray};
use serde_json::{json, Value};

pub fn norm_aes_decryption(encrypted_data: String) -> Result<Value, String> {
    let key = "QLFZQhdUmCgQa5cSBYlHFeqGYw0yPtWiqvCbdYIlQnq3jDMSaWnjR0FjeeyIU8rd";

    let key_bytes = key.as_bytes();
    let key = GenericArray::from_slice(key_bytes);
    let cipher = Aes256::new(&key);

    println!("{:?}", cipher);
    Ok(json!({}))
}

Error Output:

assertion `left == right` failed
  left: 64
 right: 32
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Hi,

You shouldn't use ASCII text as an encryption key. Encryption keys for pretty much all algorithms (and AES in particular) must be uniformly sampled bytes (not characters), otherwise no security guarantees hold. No ASCII (or UTF-8) string can fit this criteria.

Please use a library that provides a higher level API for dealing with cryptography, such as one of the libsodium bindings (sadly there are no widely used bindings for that in Rust, so use crates for that at your own peril). Using AES securely means a lot more that just using the standard APIs. There are a lot of footguns regarding the origins of the key, authentication and block modes of operations. The "raw" APIs such as node's crypto and the crates like ring or RustCrypto are not meant to be used as-is (don't roll your own crypto also includes creating your own protocols).

Now for the actual reason for the error, your key is 64 bytes long, while AES-256 expects 32 bytes (256 bits). This probably means that node truncates your key. But please, don't just fix the problem, talk to a cryptographer about what you're trying to achieve.

2 Likes

I need to decrypt the data using the same key that was used for encryption in Node.js. Is there a way to achieve this?

Nodeā€™s deprecated crypto.createCipher() function uses a key-derivation function, so itā€™s not quite that bad. Unfortunately, the key derivation function is a single iteration of MD5, so itā€™s not that good either. That would have to be re-implemented in Rust to use the same key value.

It looks like Node probably uses PKCS#7 padding.

In this case, how should we handle the IV? Is it necessary, and where can I find the official documentation or code for this?

The available documentation doesnā€™t quite spell everything out. The Node behaviour can be found in old versions of the documentation, like at Crypto | Node.js v18.20.6 Documentation, where it explains that it uses OpenSSLā€™s EVP_BytesToKey function, and that function is described at EVP_BytesToKey - OpenSSL Documentation.

After testing the OpenSSL algorithm with a bit of C, I came up with this Rust equivalent:

    let key_material = b"QLFZQhdUmCgQa5cSBYlHFeqGYw0yPtWiqvCbdYIlQnq3jDMSaWnjR0FjeeyIU8rd";
    let mut key = [0u8; 32];
    let mut iv = [0u8; 16];

    // First 16 bytes come from just the input key.
    let mut md5 = Md5::new();
    md5.update(key_material);
    md5.finalize_into_reset((&mut key[0..16]).into());

    // Next 16 bytes come from the first 16 bytes output and the input key.
    md5.update(&key[0..16]);
    md5.update(key_material);
    md5.finalize_into_reset((&mut key[16..32]).into());

    // Last 16 bytes for the IV come from the previous 16 bytes output and the input key.
    md5.update(&key[16..32]);
    md5.update(key_material);
    md5.finalize_into((&mut iv).into());

The encryption is then pretty standard, but dated, CBC with PKCS#7 padding:

    let message = "ģ•ˆė…•ķ•˜ģ„øģš” ģ„øģƒ";

    let cipher = cbc::Encryptor::<Aes256Enc>::new(&key.into(), &iv.into());
    let enc = cipher.encrypt_padded_vec_mut::<Pkcs7>(message.as_bytes());
    println!("Encrypted: {}", hex::encode(&enc));

    let cipher = cbc::Decryptor::<Aes256Dec>::new(&key.into(), &iv.into());
    match cipher.decrypt_padded_vec_mut::<Pkcs7>(&enc) {
        Ok(dec) => println!("Decrypted: {}", String::from_utf8_lossy(&dec)),
        Err(_) => println!("Invalid padding")
    }

There are a few problems with doing things this way:

  • The key derivation is not as strong as it could be, being based on MD5, but if itā€™s kept secret this is not really the biggest problem.
  • If every message reuses the same key and IV, and the messages all start with similar data (like an identical XML or JSON header), CBC reveals this structure because the encrypted bytes only start to differ when the plaintext differs.
  • CBC does not verify that the message has not been tampered with, which is even worse because of the previous problem. You can mitigate this by appending an HMAC to the message, or by using an encryption mode like AES-GCM.
  • (However, reusing the key and IV with AES-GCM leads to a catastrophic security failure, quickly leading to compromise of the private key.)
  • CBC and PKCS#7 padding have a very interesting failure mode if attackers have a way to check whether the padding is correct, as in the Err branch in my code. In this case, a padding oracle attack lets the attacker quickly decrypt the entire message without needing the key at all. Itā€™s really quite clever, and not that hard to implement. Appending an HMAC or using AES-GCM fixes this, too.

These are some of the reasons why crypto.createCipher() doesnā€™t even exist in recent versions of Node.js. If at all possible, you should switch to a more secure cipher mode with a per-message IV or nonce, and some kind of message authentication.

2 Likes