Search code examples
javascriptcryptographyaescryptojs

How to decrypt large files (1 GB) with AES-CTR chunk by chunk in javascript (browsers)?


We are trying to decrypt large files (1GB) in browsers. With AES-CTR it should be possible to decrypt chunk by chunk - where chunk must be the correct size and you also have to provide nonce + counter. Does anyone have any examples or ideas how to do this in javascript?

What we tried so far:

var length = value.byteLength;
var chunkSize = 128;
var index = 0;
let chunks = [];
let aesCounter = byteArrayToLong(subtleIv);
do {
    let newCount = aesCounter + index / 16;
    var decrypted = await window.crypto.subtle.decrypt({name: "AES-CTR", counter: Buffer.from(longToByteArray(newCount)), length: chunkSize}, subtleKey, value.slice(index, index+chunkSize));
    chunks.push(Buffer.from(decrypted));
    index += chunkSize;
} while(index < length);
let newCount = aesCounter + index / 16;
decrypted = await window.crypto.subtle.decrypt({name: "AES-CTR", counter: Buffer.from(longToByteArray(newCount)), length: chunkSize}, subtleKey, value.slice(index, index+chunkSize));
chunks.push(Buffer.from(decrypted));

let decryptedAll = Buffer.concat(chunks);


function longToByteArray(/*long*/long) {
    var byteArray = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];

    for ( var index = 0; index < byteArray.length; index ++ ) {
        var byte = long & 0xff;
        byteArray [ index ] = byte;
        long = (long - byte) / 256 ;
    }

    return byteArray;
}

function byteArrayToLong(/*byte[]*/byteArray) {
    var value = 0;
    for ( var i = byteArray.length - 1; i >= 0; i--) {
        value = (value * 256) + byteArray[i];
    }

    return value;
}

Solution

  • The only flaw in your implementation is actually the conversion between integer and byte array. Firstly, in JavaScript the maximum integer is 0x1FFFFFFFFFFFFF, see here, secondly, even with smaller numbers the little endian order is used, but the WebCrypto API applies the big endian order.

    As a first step to a fix you could use e.g. the BigInt implementation of JavaScript and the here described conversion between BigInt and ArrayBuffer.
    Since this implementation works with ArrayBuffer and Uint8Array respectively, an implementation for concatenation is needed, e.g. from here.

    This changes your implementation slightly as follows (key, IV and ciphertext are imported hex encoded):

    (async () => {
    
    // Key import
    var keyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
    var key = hex2ab(keyHex);
    var subtleKey = await window.crypto.subtle.importKey(
        "raw", 
        key,
        { name: "AES-CTR" },
        false, 
        ["encrypt", "decrypt"] 
    );
    
    // IV import
    var ivHex = "404142434445464748494a4b4c4d4e4f";
    var subtleIv = hex2ab(ivHex);
    
    // Ciphertext import
    var ciphertextHex = "ef11ad5afa7ad39fe00e0fe7e934dd38c2556dfadcce052cee9d91ee33393b428013d78ed995d5248cadd7be2d855e1adc932167d779923170447505c164eb46b59efd59e695de56512366738072afee57c16a71583346e0eac4a52dbb423b86e1c931ed7bdc3bbc17e5c662ad9cf676a7053ed435eb0968e6b1108531e2f686f491a8e2c02e43edda8162407b9e774f517e8cc8c683bada7044b1573d501a2ac54022ca1e98e26fa0f6ab60485124adb76472af0a5780a0fc2c3332cceed5395339aef3c818996bd24dd5a8d3573eab4646de859b318810dee23fb4558be8932ab790bd87d5f66531943a8bf7c70ea21b44aca6285e1e48a5852fcfa2beda61cd9f0745b8e6c10161678743b307e4ccfcb49e4c44216c32dd7e65a9f408e0aca457a9a92223e14d5d48c7855db0f7cf97e1dd176391beb0c4ecc466c9a6c4cdb211540cfd0448f4cc35b9719f31c9caf440d2aab66a42f92c65993b216449cef809ca65152bd0b509de4a7f859d630e4a5cb5c17eb6815ed1291379fe547801c7ab22501d2da6fd73111697d275b4086b455e66a36e9e8ad62f1910a832e9461606d88c407e6969f2044ff34417d391d0f6c97480264fd3c7e1b45acc";
    var ciphertext = hex2ab(ciphertextHex); 
    
    // Decrypt and concat
    var length = ciphertext.byteLength;
    var chunkSize = 128; // chunkSize in bytes
    var index = 0;
    var chunks = [];
    var aesCounter = bufToBn(subtleIv);
    do {
        var newCount = aesCounter + BigInt(index / 16); // index / 16 = number of blocks
        var decrypted = await window.crypto.subtle.decrypt({name: "AES-CTR", counter: bnToBuf(newCount), length: 128}, subtleKey, ciphertext.slice(index, index+chunkSize)); // length in bits
        chunks.push(new Uint8Array(decrypted));
        index += chunkSize;
    } while(index < length);
    var mergedChunks = merge(chunks);
    
    // Decode and output
    var decrypted = String.fromCharCode.apply(null, mergedChunks);
    console.log(decrypted);
    
    // https://coolaj86.com/articles/convert-js-bigints-to-typedarrays/
    function bnToBuf(bn) {
        var hex = BigInt(bn).toString(16);
        if (hex.length % 2) { hex = '0' + hex; }
        var len = hex.length / 2;
        var u8 = new Uint8Array(len);
        var i = 0;
        var j = 0;
        while (i < len) {
            u8[i] = parseInt(hex.slice(j, j+2), 16);
            i += 1;
            j += 2;
        }
        return u8;
    }
    function bufToBn(buf) {
        var hex = [];
        u8 = Uint8Array.from(buf);
        u8.forEach(function (i) {
            var h = i.toString(16);
            if (h.length % 2) { h = '0' + h; }
            hex.push(h);
        });
        return BigInt('0x' + hex.join(''));
    }
    
    // https://stackoverflow.com/a/49129872/9014097
    function merge(chunks){
        let size = 0;
        chunks.forEach(item => {
            size += item.length;
        });
        let mergedArray = new Uint8Array(size);
        let offset = 0;
        chunks.forEach(item => {
            mergedArray.set(item, offset);
            offset += item.length;
        });
        return mergedArray;
    }
    
    function hex2ab(hex){
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {
            return parseInt(h, 16)}));
    }
    
    })();

    which successfully decrypts the ciphertext. Btw, the ciphertext was generated with CyberChef.


    Unlike the WebCrypto API, CryptoJS supports progressive encryption, so the same logic can be implemented significantly easier with CryptoJS:

    // Key import
    var keyHex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
    var keyWA = CryptoJS.enc.Hex.parse(keyHex);
    
    // IV import
    var ivHex = "404142434445464748494a4b4c4d4e4f";
    var ivWA = CryptoJS.enc.Hex.parse(ivHex);
    
    // Ciphertext import
    var ciphertextHex = "ef11ad5afa7ad39fe00e0fe7e934dd38c2556dfadcce052cee9d91ee33393b428013d78ed995d5248cadd7be2d855e1adc932167d779923170447505c164eb46b59efd59e695de56512366738072afee57c16a71583346e0eac4a52dbb423b86e1c931ed7bdc3bbc17e5c662ad9cf676a7053ed435eb0968e6b1108531e2f686f491a8e2c02e43edda8162407b9e774f517e8cc8c683bada7044b1573d501a2ac54022ca1e98e26fa0f6ab60485124adb76472af0a5780a0fc2c3332cceed5395339aef3c818996bd24dd5a8d3573eab4646de859b318810dee23fb4558be8932ab790bd87d5f66531943a8bf7c70ea21b44aca6285e1e48a5852fcfa2beda61cd9f0745b8e6c10161678743b307e4ccfcb49e4c44216c32dd7e65a9f408e0aca457a9a92223e14d5d48c7855db0f7cf97e1dd176391beb0c4ecc466c9a6c4cdb211540cfd0448f4cc35b9719f31c9caf440d2aab66a42f92c65993b216449cef809ca65152bd0b509de4a7f859d630e4a5cb5c17eb6815ed1291379fe547801c7ab22501d2da6fd73111697d275b4086b455e66a36e9e8ad62f1910a832e9461606d88c407e6969f2044ff34417d391d0f6c97480264fd3c7e1b45acc";
    var ciphertextWA = CryptoJS.enc.Hex.parse(ciphertextHex);
    
    // Decrypt and concat
    var length = ciphertextWA.sigBytes;
    var chunkSize = 128;
    var index = 0;
    var decryptedWA = CryptoJS.enc.Hex.parse("");
    var aesDecryptor = CryptoJS.algo.AES.createDecryptor(keyWA, { iv: ivWA, mode: CryptoJS.mode.CTR, padding: CryptoJS.pad.NoPadding });
    var chunk = null;
    do {   
        chunk = CryptoJS.lib.WordArray.create(ciphertextWA.words.slice(index/4, (index + chunkSize)/4));
        decryptedWA = decryptedWA.concat(aesDecryptor.process(chunk));
        index += chunkSize;
    } while(index < length - chunkSize);
    chunk = CryptoJS.lib.WordArray.create(ciphertextWA.words.slice(index/4, (index + chunkSize)/4));
    chunk.sigBytes = length - index;
    chunk.clamp();
    decryptedWA = decryptedWA.concat(aesDecryptor.process(chunk));
    decryptedWA = decryptedWA.concat(aesDecryptor.finalize());
    
    // Decode and output
    var decrypted = decryptedWA.toString(CryptoJS.enc.Utf8);
    console.log(decrypted);
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>


    A drawback of the first variant is that the BigInt class should actually not be used in the context of cryptography, since the operations are not constant-time, which results in a vulnerability to timing attacks. So, here you would have to apply a cryptographically more secure JavaScript BigInteger implementation for production.
    For such reasons, the use of an established library (as opposed to a custom implementation), such as CryptoJS, is generally more secure (although ultimately vulnerabilities cannot be ruled out here either).