Search code examples
javascriptnode.jsactionscript-3as3crypto

AES broken between Node.js and Actionscript (as3crypto)


I'm trying to make AES-256 encryption work across node.js and actionscript, but every approach I'm trying is leading to a deadend. Below are two different attempts which both fail (for different reasons).

One important thing to note is that in both cases- the IV, Key, and Ciphertext are matching perfectly.

Excuse the code repetition but I figured it's better to just show exactly what I'm working with...

1) Default Padding

When using the default Node.JS padding and PKCS5 in as3, I get an Error: PKCS#5:unpad: Invalid padding value. expected [105], found [30].

Node.JS

var CIPHER_METHOD = "aes-256-cbc";

function aesEncryptStringToHex(input, key, iv) {
    var aesCipher = crypto.createCipher(CIPHER_METHOD, key, iv);
    var plainText = new Buffer(input, 'utf8').toString('hex');  
    var output;

    output = aesCipher.update(input, 'utf8', 'hex') + aesCipher.final('hex');

    console.log('IV: ' + iv.toString('hex'));
    console.log('Key: ' + key.toString('hex'));
    console.log('Plaintext: ' + plainText);
    console.log('Ciphertext: ' + output);
    sendToFlash(iv.toString('hex') + output);
}

AS3

private function aesDecryptToBytes(cipherBA:ByteArray, key:ByteArray):ByteArray {
    var IV:ByteArray = new ByteArray();
    var finalBytes:ByteArray = new ByteArray();
    var retBytes:ByteArray;
    var aesKey:AESKey;
    var cbcMode:CBCMode;
    var pad:PKCS5;
    var testOnly:ByteArray = new ByteArray();
    testOnly.writeUTFBytes('Hello World');

    if(key.length != 32) {
        throw new Error("INVALID KEY!");
    }


    if(cipherBA.length < 17) {
        throw new Error("INVALID CONTENT!");
    }
    cipherBA.readBytes(IV,0,16);
    cipherBA.readBytes(finalBytes, 0);

    IV.position = finalBytes.position = 0;

    trace('IV:', Hex.fromArray(IV));
    trace('Key:', Hex.fromArray(key));
    trace('Ciphertext:', Hex.fromArray(finalBytes));
    trace('Decrypted Plaintext Should Be:', Hex.fromArray(testOnly));

    pad = new PKCS5();
    aesKey = new AESKey(key);
    cbcMode = new CBCMode(aesKey,pad);
    cbcMode.IV = IV;
    pad.setBlockSize(cbcMode.getBlockSize());

    cbcMode.decrypt(finalBytes);

    retBytes = finalBytes;

    retBytes.position = 0;

    trace('But instead it is:', Hex.fromArray(retBytes));

    return(retBytes);
}

When using "HELLO WORLD!" for the input and the same key for both, I get

Output on Node.JS side

IV: 87134386f7bf12dffc9b87b49da86d10

Key: 56036ce4ddab006af7b0924ddad511adbea3fba97f672db4040102a1978e41f7

Plaintext: 48454c4c4f20574f524c4421

Ciphertext: d68db4542be683a80bceb0b8ca900d5c

Output on AS3 side

IV: 87134386f7bf12dffc9b87b49da86d10

Key: 56036ce4ddab006af7b0924ddad511adbea3fba97f672db4040102a1978e41f7

Ciphertext: d68db4542be683a80bceb0b8ca900d5c

Decrypted Plaintext Should Be: 48454c4c4f20574f524c4421

Error: PKCS#5:unpad: Invalid padding value. expected [105], found [30]

2) Custom and Null Padding

When disabling the default Node.JS padding and filling with null characters, and then using NullPad on as3, I get no errors but the decryption fails

Node.JS

var CIPHER_METHOD = "aes-256-cbc";
var AES_BLOCK_SIZE = 16;
var AES_PAD_STARTER = Array(16).join('\0');

function aesEncryptStringToHex(input, key, iv) {
    var aesCipher = crypto.createCipher(CIPHER_METHOD, key, iv);
    var plainText = new Buffer(input, 'utf8').toString('hex');
    var padLength = AES_BLOCK_SIZE - (input.length % AES_BLOCK_SIZE);
    var output;

    aesCipher.setAutoPadding(false);
    input += AES_PAD_STARTER.substr(0, padLength);

    output = aesCipher.update(input, 'utf8', 'hex') + aesCipher.final('hex');

    console.log('IV: ' + iv.toString('hex'));
    console.log('Key: ' + key.toString('hex'));
    console.log('Plaintext: ' + plainText);
    console.log('Ciphertext: ' + output);
    sendToFlash(iv.toString('hex') + output);
}

AS3

private function aesDecryptToBytes(cipherBA:ByteArray, key:ByteArray):ByteArray {
    var IV:ByteArray = new ByteArray();
    var finalBytes:ByteArray = new ByteArray();
    var retBytes:ByteArray;
    var aesKey:AESKey;
    var cbcMode:CBCMode;
    var pad:NullPad;
    var testOnly:ByteArray = new ByteArray();
    testOnly.writeUTFBytes("HELLO WORLD!");

    if(key.length != 32) {
        throw new Error("INVALID KEY!");
    }


    if(cipherBA.length < 17) {
        throw new Error("INVALID CONTENT!");
    }
    cipherBA.readBytes(IV,0,16);
    cipherBA.readBytes(finalBytes, 0);

    IV.position = finalBytes.position = 0;

    trace('IV:', Hex.fromArray(IV));
    trace('Key:', Hex.fromArray(key));
    trace('Ciphertext:', Hex.fromArray(finalBytes));
    trace('Decrypted Plaintext Should Be:', Hex.fromArray(testOnly));

    pad = new NullPad();
    aesKey = new AESKey(key);
    cbcMode = new CBCMode(aesKey,pad);
    cbcMode.IV = IV;
    pad.setBlockSize(cbcMode.getBlockSize());

    cbcMode.decrypt(finalBytes);

    retBytes = finalBytes;

    retBytes.position = 0;

    trace('But instead it is:', Hex.fromArray(retBytes));

    return(retBytes);
}

When using "HELLO WORLD!" for the input and the same key for both, I get

Output on Node.JS side

IV: cfa6cfee9f81d81d7e3b651e57b6f42d

Key: 56036ce4ddab006af7b0924ddad511adbea3fba97f672db4040102a1978e41f7

Plaintext: 48454c4c4f20574f524c4421

Ciphertext: 8daf432aad551e333818c42d3190dca5

Output on AS3 side

IV: cfa6cfee9f81d81d7e3b651e57b6f42d

Key: 56036ce4ddab006af7b0924ddad511adbea3fba97f672db4040102a1978e41f7

Ciphertext: 8daf432aad551e333818c42d3190dca5

Decrypted Plaintext Should Be: 48454c4c4f20574f524c4421

But instead it is: 70a4716a7a7d7156bca075efe90041a3

Note that trying retBytes.readUTFBytes(retBytes.length) yields garbage as well.

Any way to make AES encryption work across both platforms?!


EDIT: For the sake of posterity, some node code which works for encrypting and decrypting, with comments to illustrate the potential gotchas:

const CIPHER_METHOD = "aes-256-cbc";
const AES_BLOCK_SIZE = 16;

let nullPad = new Buffer(AES_BLOCK_SIZE).fill(0);

function aesEncrypt(input, key, iv) {
    if(iv === undefined) {
      //create a random iv.. this is the norm for encryption
      iv = crypto.randomBytes(AES_BLOCK_SIZE);
    }
    let aesCipher = crypto.createCipheriv(CIPHER_METHOD, key, iv);
    let padLength = AES_BLOCK_SIZE - (input.length % AES_BLOCK_SIZE);

    //don't pad if it's an entire block's worth
    if(padLength === AES_BLOCK_SIZE) {
      padLength = 0;
    }

    //we're controlling the padding manually here so we can match it in other environments
    aesCipher.setAutoPadding(false);

    //for now, just a simple null pad. Need to add it before encryption
    //if it were pcks#7 or something, the length would not need to be returned for later use
    if(padLength > 0) {
      input = Buffer.concat([input, nullPad.slice(0, padLength)]);
    }

    //encrypt it
    let cipherText = Buffer.concat([aesCipher.update(input), aesCipher.final()])

    return {
      cipherText: cipherText,
      iv: iv,
      padLength: padLength,
    }
}

function aesDecrypt(cipherText, key, iv, padLength) {
    if(iv === undefined) {
      //strip the iv off the front
      iv = cipherText.slice(0,AES_BLOCK_SIZE);  
      cipherText = cipherText.slice(AES_BLOCK_SIZE);
    }

    let aesCipher = crypto.createDecipheriv(CIPHER_METHOD, key, iv);

    //turn off padding so we can match it in other environments
    aesCipher.setAutoPadding(false);

    //decrypt it
    let plaintext = Buffer.concat([aesCipher.update(cipherText), aesCipher.final()]);

    //for now, just a simple null padding. Need to strip it after decryption
    //if it were pcks#7 or something, the length would be auto-determined
    plaintext = plaintext.slice(0,plaintext.length - padLength);


    return plaintext;
}

function testRun(original, key) {
  //cipher is an object containing ciphertext, iv, and padLength
  let cipher = aesEncrypt(original, key);

  //treat the iv separately from the ciphertext. This is nice though hurlant doesn't support that afaik
  let decryptedSeparate = aesDecrypt(cipher.cipherText, key, cipher.iv, cipher.padLength);

  //combine the iv with the ciphertext directly. aesDecrypt will strip it automatically
  let combinedCipherIv = Buffer.concat([cipher.iv, cipher.cipherText]);
  let decryptedCombined = aesDecrypt(combinedCipherIv, key, undefined, cipher.padLength);

  //Show the results!
  console.log("Original: " + original.toString('utf8'));
  console.log("Encrypted: " + cipher.cipherText.toString('utf8'));
  console.log("Padding size: " + cipher.padLength);
  console.log("Plaintext from combined: " + decryptedCombined.toString('utf8'));
  console.log("Plaintext from separate: " + decryptedSeparate.toString('utf8'));
}

//key should be something more secure than whatever happens to be in memory at the moment ;)
let key = new Buffer(32);

//original is just binary data... doesn't have to be a string, though it's easier to see in the console for testing
//this test is for no padding
let original = new Buffer("0123456789ABCDEF", 'utf8');
testRun(original, key);

console.log("");

//this test is with some padding
original = new Buffer("HELLO WORLD", 'utf8');
testRun(original, key);

Solution

  • Well, I kind of have the same problem between node.js and Objective-C, but in teh nodeJS code you should be using crypto.CreateCypheriv, not just createCypher, and maybe try not just with key and IV as buffer but also change the key and IV to bynary on both ends as working with arrays may sometimes be troublesome... I hope this gives you some ideas to solve your problem