Vitor Eiji
Vitor Eiji

Reputation: 243

When using AES-CBC in WebCrypto, why does a wrong key cause an OperationError but a wrong ciphertext or IV don't?

As far as I understand, using a wrong key (with the correct size) to decrypt something with AES-CBC should just output some garbage. CBC doesn't have any sort of MAC, so you really can only look at the results of the decryption and decide for yourself if that is the plaintext you want.

However, when decrypting with SubtleCrypto, a wrong key will cause an OperationError, but a wrong ciphertext will not, and neither will a wrong IV. I would have expected all these three cases to have similar behaviours.

How is it possible for the implementation to know that the key was wrong and not any of the other inputs? Do keys have to have a specific structure, other than the size? In that case, the key space would be smaller than the advertised bit length of the key, no?

async function simpleCryptoTest() {
    // all zeroes plaintext, key and IV
    const iv = new ArrayBuffer(16)
    const key = new ArrayBuffer(32)
    const plaintext = new ArrayBuffer(64)

    const algorithm = {name: 'AES-CBC'};
    const correctCryptoKey = await crypto.subtle.importKey('raw', key, algorithm, false, ['encrypt', 'decrypt'])
    const ciphertext = await crypto.subtle.encrypt({...algorithm, iv: iv}, correctCryptoKey, plaintext)
    console.log("ciphertext", ciphertext)

    const decryptedCorrect = crypto.subtle.decrypt({...algorithm, iv: iv}, correctCryptoKey, ciphertext)

    const wrongCiphertext = new Uint8Array(ciphertext)
    wrongCiphertext[0] = ~ciphertext[0] // flipping the first byte should be enough
    const decryptedWrongCiphertext = crypto.subtle.decrypt({...algorithm, iv: iv}, correctCryptoKey, wrongCiphertext)

    const wrongIv = new Uint8Array(iv)
    wrongIv[0] = 1 // we know the correct IV is all zeroes
    const decryptedWrongIv = crypto.subtle.decrypt({...algorithm, iv: wrongIv}, correctCryptoKey, ciphertext)

    const wrongKey = new Uint8Array(key)
    wrongKey[0] = ~key[0]
    const decryptedWrongKey = crypto.subtle.importKey('raw', wrongKey, algorithm, false, ['decrypt']).then((wrongCryptoKey) => {
        return crypto.subtle.decrypt({...algorithm, iv: iv}, wrongCryptoKey, ciphertext)
    })

    const results = await Promise.allSettled([decryptedCorrect, decryptedWrongCiphertext, decryptedWrongIv, decryptedWrongKey])
    console.log("decrypted with the correct key", results[0])
    console.log("decrypted with corrupted ciphertext", results[1])
    console.log("decrypted with corrupted IV", results[2])
    console.log('decrypted with the wrong key', results[3])
}

simpleCryptoTest()

/*
    decrypted with the correct key → {status: "fulfilled", value: ArrayBuffer(64)}
    decrypted with corrupted ciphertext → {status: "fulfilled", value: ArrayBuffer(64)}
    decrypted with corrupted IV → {status: "fulfilled", value: ArrayBuffer(64)}
    decrypted with the wrong key → {status: "rejected", reason: DOMException} // e.name == 'OperationError'
*/

Please note that I am aware that CBC has no authentication, and I am aware that GCM exists. I need CBC because I am implementing a variation of the Signal Protocol, which I most certainly do not intend to roll out in production without a proper crypto review. Thanks :-)

Also, I tested this on Firefox 77.0.1 and Chromium 83.0.4103.97 for Linux.

Upvotes: 0

Views: 1027

Answers (1)

President James K. Polk
President James K. Polk

Reputation: 41967

There is no MAC, but there is padding. I'm not very familiar with WebCrypto but chances are you are using PKCS7 padding in your encryption algorithm specification -- either explicitly or by default. The padding bytes added to the end of the plaintext have the value k k ... k, where k is number of padding bytes needed, 1 <= k <= 16. Upon decryption, a check is made if the last byte k is in the range specified, and if the last k bytes are equal to k. If that check fails then something has gone wrong and the OperationError is returned.

Now, as for corrupted IV and corrupted ciphertext, the reason it works is a "feature" of CBC mode. If you look carefully at the diagram of the decrypt direction of CBC mode you'll note that following facts (remember, this is on decryption):

  1. A corrupted IV affects only the first block of plaintext. All the rest decrypt correctly
  2. A corrupted ciphetext affects only the current block of plaintext and the next block. All the blocks before and after decrypt correctly.

Therefore, try changing the ciphertext block before the last block and you should see your OperationError. However, the padding check is no substitute for a real MAC, and even with a corrupted key or last or next-to-last ciphertext block there is still a decent chance that the padding check will succeed. If the last byte of the final decrypted block equals 1 then the padding check succeeds. This probability of this is 1/256 for the corrupted items listed. (It is actually a little higher because if the last two bytes are equal 2, or the last 3 bytes are equal to 3,... etc., then the padding check also succeeds). So as an experiment try changing two bytes of the key about 500 or so times and you should 1 or 2 instances where the decryption succeeds without error.

Upvotes: 1

Related Questions