javascriptphpnode.jscryptographymcrypt

NodeJS Crypto TripleDes decryption (mcrypt port)


I am struggling with some legacy-code written in PHP 5.5 and mcrypt. I want to create a backward-compatible functionality in Node.js so in the result I have to port code below to newer standards.

public function decr($hash) {
        $decoded = base64_decode($hash);

        $decodedShorter = substr($decoded, 0, -8);
        $iv = substr($decoded, -8);
        $decr = rtrim(@mcrypt_decrypt(MCRYPT_3DES, file_get_contents('some.key'), $decodedShorter, MCRYPT_MODE_CFB, $iv));

        return $decr;
    }

I've been experimenting with multiple strategies both crypto-js and native crypto out of node engine.

The latest problem I faced:

ERR_CRYPTO_INVALID_IV


const decrypt = (text, secretKey, iv = null) => {
    const decipher = crypto.createDecipheriv('des-ede3-cfb8', secretKey, iv);
    let decrypted = decipher.update(text, 'utf8');
    decrypted += decipher.final();
    return decrypted;
};

async function main() {
    const decoded = atob(name);
    const key = await readFile(
        path.resolve(`some.key`)
    )
    
    const decodedShorter = decoded.substr(0, decoded.length - 8)
    const iv = decoded.substr(-8)
    
    
    return decrypt(decodedShorter, key, Buffer.from(iv))
}

Any ideas? Is the new openSSL implementation so different from mcrypt one that it is not compatible? Or maybe I messed up with something? I am pretty sure that types of arguments are correct as I was referring to @types/node/crypto, but there is something incorrect with content/logic itself...


Solution

  • The decr() method in the PHP code first Base64 decodes the encrypted data and then separates ciphertext and IV. Here the 8 bytes IV is expected to be appended to the ciphertext.
    After that a decryption with AES in CFB mode is performed. There are different CFB variants of different segment sizes, here a segment size of 8 bits is used. CFB is a stream cipher mode, so no padding is needed/applied.

    The bug in the posted NodeJS code is that ciphertext and IV are processed as strings using a UTF-8 encoding. This generally corrupts an arbitrary byte sequence (such as a ciphertext or an IV).
    Regarding the ciphertext, the corruption happens in decipher.update(text, 'utf8'). Here UTF-8 is explicitly specified as input encoding in the second parameter.
    Regarding the IV, the corruption happens when reading the IV into the buffer: Buffer.from(iv). Since no encoding is specified in the second parameter, UTF-8 is implicitly used. Both problems can be fixed by using latin1 as encoding.

    A more robust solution is to use buffers throughout, so that no encoding is necessary:

    var crypto = require('crypto')
    
    const decrypt = (text, secretKey, iv = null) => {
        const decipher = crypto.createDecipheriv('des-ede3-cfb8', secretKey, iv);
        let decrypted = decipher.update(text, '', 'utf8'); 
        decrypted += decipher.final('utf8');
        return decrypted;
    }
    
    const name = "OrjgCsq9EkT2TkCZzDOfW492nXQCNIC0BtVJy1FaaTXv2jXAPqx75kaUJVSG/5MCFXXq"
    const decoded = Buffer.from(name, 'base64')
    const decodedShorter = decoded.slice(0, decoded.length - 8)
    const iv = decoded.slice(decoded.length - 8)
    const key = Buffer.from('ffa3b5205582d6ea7de6439ec2bafef46a80810003158922', 'hex');
    console.log(decrypt(decodedShorter, key, iv))
    

    Test: Both codes decrypt the following ciphertext $ciphertext with the key $key into the given plaintext:

    $ciphertext = 'OrjgCsq9EkT2TkCZzDOfW492nXQCNIC0BtVJy1FaaTXv2jXAPqx75kaUJVSG/5MCFXXq';
    $key = hex2bin('ffa3b5205582d6ea7de6439ec2bafef46a80810003158922');
    // Plaintext: The quick brown fox jumps over the lazy dog