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
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.
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:
The following implementation considers both: