Search code examples
aeswebcrypto-api

WebCrypto AES-CBC outputting 256bit instead of 128bits


I'm playing with WebCrypto and I'm getting a confusing output.

The following test case encrypts a random 16byte (128bit) plain text with a newly generated 128bit key and 128bit random IV but is outputting a 32byte (256bit) output.

If I remember the details of AES-CBC it should output 128bit blocks.

function test() { 

    var data = new Uint8Array(16);
    window.crypto.getRandomValues(data);
    console.log(data)


    window.crypto.subtle.generateKey(
        {
            name: "AES-CBC",
            length: 128, 
        },
        false, 
        ["encrypt", "decrypt"] 
    )
    .then(function(key){
        //returns a key object
        console.log(key);

        window.crypto.subtle.encrypt(
            {
                name: "AES-CBC",
                iv: window.crypto.getRandomValues(new Uint8Array(16)),
            },
            key, 
            data 
        )
        .then(function(encrypted){
            console.log(new Uint8Array(encrypted));
        })
        .catch(function(err){
            console.error(err);
        });
    })
    .catch(function(err){
        console.error(err);
    });

}

Example output:

Uint8Array(16) [146, 207, 22, 56, 56, 151, 125, 174, 137, 69, 133, 36, 218, 114, 143, 174]
CryptoKey {
   algorithm: {name: "AES-CBC", length: 128}
   extractable: false
   type: "secret"
   usages: (2) ["encrypt", "decrypt"]
   __proto__: CryptoKey
Uint8Array(32) [81, 218, 52, 158, 115, 105, 57, 230, 45, 253, 153, 54, 183, 19, 137, 240, 183, 229, 241, 75, 182, 19, 237, 8, 238, 5, 108, 107, 123, 84, 230, 209]

Any idea what I've got wrong.

(Open to moving to crypto.stackexchange.com if more suitable)

I'm testing on Chrome 71 on MacOS at the moment.


Solution

  • Yes. The extra 16 bytes is the padding. Even when the message text is a multiple of the block size, padding is added, otherwise the decryption logic doesn't know when to look for padding.

    The Web Cryptography API Specification says:

    When operating in CBC mode, messages that are not exact multiples of the AES block size (16 bytes) can be padded under a variety of padding schemes. In the Web Crypto API, the only padding mode that is supported is that of PKCS#7, as described by Section 10.3, step 2, of [RFC2315].

    This means unlike other language implementations (like Java) where you can specify NoPadding when you know that your input message text is always going to be a multiple of block size (128 bits for AES), Web Cryptography API forces you to have PKCS#7 padding.

    If we look into RFC2315:

    Some content-encryption algorithms assume the input length is a multiple of k octets, where k > 1, and let the application define a method for handling inputs whose lengths are not a multiple of k octets. For such algorithms, the method shall be to pad the input at the trailing end with k - (l mod k) octets all having value k - (l mod k), where l is the length of the input. In other words, the input is padded at the trailing end with one of the following strings:

         01 -- if l mod k = k-1
        02 02 -- if l mod k = k-2
                    .
                    .
                    .
      k k ... k k -- if l mod k = 0
    

    The padding can be removed unambiguously since all input is padded and no padding string is a suffix of another. This padding method is well-defined if and only if k < 256; methods for larger k are an open issue for further study.

    Note: k k ... k k -- if l mod k = 0

    If you refer to the subtle.encrypt signature, you have no way to specify the padding mode. This means, the decryption logic always expects the padding.

    However, in your case, if you use the Web Cryptography API only for encryption and your Python app (with NoPadding) only for decryption, I think you can simply strip off the last 16 bytes from the cipher text before feeding it to the Python app. Here is the code sample just for demonstration purpose:

    function test() { 
    
        let plaintext = 'GoodWorkGoodWork';
        let encoder = new TextEncoder('utf8');
        let dataBytes = encoder.encode(plaintext);
    
        window.crypto.subtle.generateKey(
            {
                name: "AES-CBC",
                length: 128, 
            },
            true, 
            ["encrypt", "decrypt"] 
        )
        .then(function(key){
            crypto.subtle.exportKey('raw', key)
            .then(function(expKey) {
                console.log('Key = ' + btoa(String.
                    fromCharCode(...new Uint8Array(expKey))));
            });
                    
            let iv = new Uint8Array(16);
            window.crypto.getRandomValues(iv);
            let ivb64 = btoa(String.fromCharCode(...new Uint8Array(iv)));
            console.log('IV = ' + ivb64);
    
            window.crypto.subtle.encrypt(
                {
                    name: "AES-CBC",
                    iv: iv,
                },
                key, 
                dataBytes 
            )
            .then(function(encrypted){
                console.log('Cipher text = ' + 
                    btoa(String.fromCharCode(...new Uint8Array(encrypted))));
            })
            .catch(function(err){
                console.error(err);
            });
        })
        .catch(function(err){
            console.error(err);
        });
    
    }
    

    The output of the above is:

    IV = qW2lanfRo2H/3aSLzxIecA==
    Key = 0LDBq5iz243HBTUE/lrM+A==
    Cipher text = Wa4nIF0tt4PEBUChiH1KCkSOg6L2daoYdboEEf+Oh6U=
    

    Now, I use take these as input, strip off the last 16 bytes of the cipher text and still get the same message text after decryption using the following Java code:

    package com.sapbasu.javastudy;
    
    import java.nio.charset.StandardCharsets;
    import java.util.Base64;
    
    import javax.crypto.Cipher;
    import javax.crypto.SecretKey;
    import javax.crypto.spec.IvParameterSpec;
    import javax.crypto.spec.SecretKeySpec;
    
    public class EncryptCBC {
      public static void main(String[] arg) throws Exception {
        
        SecretKey key = new SecretKeySpec(Base64.getDecoder().decode(
            "0LDBq5iz243HBTUE/lrM+A=="),
            "AES");
        
        IvParameterSpec ivSpec = new IvParameterSpec(Base64.getDecoder().decode(
            "qW2lanfRo2H/3aSLzxIecA=="));
            
        Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
        cipher.init(Cipher.DECRYPT_MODE, key, ivSpec);
        
        byte[] cipherTextWoPadding = new byte[16];
        System.arraycopy(Base64.getDecoder().decode(
            "Wa4nIF0tt4PEBUChiH1KCkSOg6L2daoYdboEEf+Oh6U="),
            0, cipherTextWoPadding, 0, 16);
        
        byte[] decryptedMessage = cipher.doFinal(cipherTextWoPadding);
        System.out.println(new String(decryptedMessage, StandardCharsets.UTF_8));
      }
    }