skip to Main Content

We are trying to build a stream decrypt of large files in Javascript (browsers). As stream decryption isn’t natively supported by crypto.Subtle we are doing that chunk by chunk. Which should be possible by providing the previous chunk as the iv for the current chunk.

But it seems Crypto.subtle is always expecting a padding at the end of the chunk. (Confirmed here: What padding does window.crypto.subtle.encrypt use for AES-CBC). Which makes an implementation feel strange to say the least.

Does anyone have any examples or ideas how to do this in javascript, while still using native api’s?

We have come up with this:

const padding = new Uint8Array(16).fill(16);
const chunkSize = 16;
let index = 0;
const chunks = [];
let prevChunk = null;
do {
    const chunk = ciphertext.slice(index, index + chunkSize);
    
    // Encrypt padding with the chunk as iv
    const paddingCypher = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: chunk }, key, padding);
    const encryptedPadding = (new Uint8Array(paddingCypher)).slice(0, 16);

    const decrypted = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: prevChunk || subtleIv}, subtleKey, mergeByteArrays([chunk, encryptedPadding]));
    chunks.push(new Uint8Array(decrypted));

    prevChunk = chunk;
    index += chunkSize;
} while (index < length - chunkSize);

// Different for last chunk as that already has the padding
const lastChunk = await decrypt(
    prevChunk || subtleIv,
    subtleKey,
    ciphertext.slice(index, index + chunkSize)
);
chunks.push(new Uint8Array(lastChunk));

var mergedChunks = mergeByteArrays(chunks);

// Decode and output
var fullyDecrypted = String.fromCharCode.apply(null, mergedChunks as any);
console.log(fullyDecrypted);

2

Answers


  1. Simple solution: use CTR mode. That doesn’t pad, and you can simply calculate the counter for the next chunk. You can indicate the entire (unsigned big endian) counter in AesCtrParams so you should be golden. The counter needs to be increased by 1 for every 16-byte block of data. If you’re smart then you make your chunks a multiple of 16 bytes of course.


    Beware that no cipher provides the same security as using transport mode security / TLS.

    Remember that CTR mode is unauthenticated, and although padding oracle attacks are not possible on CTR (obviously), the resulting ciphertext is vulnerable to bit flip attacks and – for that reason – plaintext oracles. So you may need to include a HMAC authentication tag over the nonce & ciphertext and verify that tag before using the decrypted data.

    So encrypting at the client with CBC or CTR is mainly useful if you want to store the data ("data at rest") on the server side.

    Login or Signup to reply.
  2. If you can switch the mode, the CTR mode suggested in the other answer is the more convenient approach, since the WebCrypto API (like most libraries) automatically disables padding for stream cipher modes (like CTR), while it is automatically enabled for block cipher modes (like CBC).

    However, if you can’t change the mode and still want to use the WebCrypto API, a ciphertext block with the encrypted padding must be added to each ciphertext chunk before decryption, as in the posted code, since the WebCrypto API does not allow the PKCS#7 padding to be disabled for block cipher modes like CBC.


    The posted code can be optimized: The current code uses 16 bytes as chunk size. Also, to determine the ciphertext block to append, a full padding block is encrypted for each ciphertext chunk, resulting in a 2-block ciphertext due to padding, of which the second block must be discarded.

    For an optimization:

    • firstly, the chunk size should be chosen as large as possible, although for simplicity it should still be an integer multiple of the block size. This way, a larger part of the encryption is performed by the library’s own implementation of the operation mode (which may be more performant).
    • secondly, to determine the ciphertext block to append, an empty block should be encrypted, which just provides the ciphertext block to append due to the padding. This means that less is encrypted and less must be discarded.

    The following implementation considers both:

    (async () => {
    
    const ciphertext = b642ab('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');
    const key = await crypto.subtle.importKey("raw", b642ab('MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE='), { name: "AES-CBC" }, true, ["decrypt", "encrypt"]);
    const iv = b642ab('MDEyMzQ1Njc4OTAxMjM0NQ==');
    let plaintext = await testDecrypt(ciphertext, key, iv, 16);
    console.log(plaintext);
    
    async function testDecrypt(ciphertext, key, iv, chunkSizeInBlocks) {
        const length = ciphertext.byteLength;
        const blocksize = 16;
        const chunkSize = chunkSizeInBlocks * blocksize; 
        let index = 0;
        const plaintextChunks = [];        
        while (index < length - chunkSize) { // Process all chunks except the last one
            const ciphertextChunk = ciphertext.slice(index, index + chunkSize);
            const lastCiphertextBlock = ciphertextChunk.slice(-blocksize);
            const padCiphertextBlock = await crypto.subtle.encrypt({ name: 'AES-CBC', iv: lastCiphertextBlock }, key, new Uint8Array());
            const fullCiphertextChunk = concat(ciphertextChunk, new Uint8Array(padCiphertextBlock));
            const plaintextChunk = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, key, fullCiphertextChunk); 
            plaintextChunks.push(...new Uint8Array(plaintextChunk)); 
            iv = lastCiphertextBlock;
            index += chunkSize;
        } 
        const ciphertextChunk = ciphertext.slice(index, index + chunkSize); // Process last chunk
        const plaintextChunk = await crypto.subtle.decrypt({ name: 'AES-CBC', iv: iv }, key, ciphertextChunk); 
        plaintextChunks.push(...new Uint8Array(plaintextChunk));  
        return String.fromCharCode.apply(null, plaintextChunks);
    }
    function concat(a, b) { 
        const c = new (a.constructor)(a.length + b.length);
        c.set(a, 0);
        c.set(b, a.length);
        return c;
    }
    function b642ab(base64String){
        return Uint8Array.from(window.atob(base64String), c => c.charCodeAt(0));
    }
    
    })();
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search