Search code examples
javascriptpythoncryptographywebcrypto-apipython-cryptography

Encrypt in JS front end and decrypt in python backend using AES GCM


I am trying encrypting in JS front end and decrypt in python backend using AES GCM cryptographic algorithm. I am using Web cryptography api for JS front end and python cryptography library for python backend as cryptographic library. I have fixed the IV for now in both side. I have implemented encryption-decryption code in both side, they work on each side. But I think the padding is done differently, can't seem to figure out how the padding is done in web cryptography api. Here is the encryption and decryption for the python backend:

def encrypt(derived_key, secret):
    IV = bytes("ddfbccae-b4c4-11", encoding="utf-8")
    aes = Cipher(algorithms.AES(derived_key), modes.GCM(IV))
    encryptor = aes.encryptor()
    padder = padding.PKCS7(128).padder()
    padded_data = padder.update(secret.encode()) + padder.finalize()
    return encryptor.update(padded_data) + encryptor.finalize()

def decrypt(derived_key, secret): 
    IV = bytes("ddfbccae-b4c4-11", encoding="utf-8")
    aes = Cipher(algorithms.AES(derived_key), modes.GCM(IV))
    decryptor = aes.decryptor()
    decrypted_data = decryptor.update(secret) 
    unpadder = padding.PKCS7(128).unpadder()
    return unpadder.update(decrypted_data) + unpadder.finalize()

Here's the JS code for encryption and decryption code:

async function encrypt(secretKey, message) {
  let iv = "ddfbccae-b4c4-11";
  iv = Uint8Array.from(iv, x => x.charCodeAt(0))
  let encoded = getMessageEncoding(message);
  ciphertext = await window.crypto.subtle.encrypt(
    {
      name: "AES-GCM",
      iv: iv
    },
    secretKey,
    encoded
  );
  return ciphertext;
}

async function decrypt(secretKey, cipherText) {
  iv = "ddfbccae-b4c4-11";
  iv = Uint8Array.from(iv, x => x.charCodeAt(0))
  try {
    let decrypted = await window.crypto.subtle.decrypt(
      {
        name: "AES-GCM",
        iv: iv
      },
      secretKey,
      cipherText
    );

    let dec = new TextDecoder();
    console.log("Decrypted message: ");
    console.log(dec.decode(decrypted));
   
  } catch (e) {
    console.log("error");
    
  }
}

I try to encrypt in the JS side and decrypt in the python side. But I got the following error: enter image description here

If I try to encrypt the same string in both side I got these outputs: In python the encrypted text: \x17O\xadn\x11*I\x94\x99\xc6\x90\x8a\xa9\x9cc=

In JS the encrypted text: \x17O\xadn\x11*I\xdf\xe3F\x81(\x15\xcc\x8c^z\xdf+\x1d\x91K\xbc

How to solve this padding issue?


Solution

  • GCM is a stream cipher mode and therefore does not require padding. During encryption, an authentication tag is implicitly generated, which is used for authentication during decryption. Also, an IV/nonce of 12 bytes is recommended for GCM.

    The posted Python code unnecessarily pads and doesn't take the authentication tag into account, unlike the JavaScript code, which may be the main reason for the different ciphertexts. Whether this is the only reason and whether the JavaScript code implements GCM correctly, is difficult to say, since the getMessageEncoding() method was not posted, so testing this was not possible.

    Also, both codes apply a 16 bytes IV/nonce instead of the recommended 12 bytes IV/nonce.


    Cryptography offers two possible implementations for GCM. One implementation uses the architecture of the non-authenticating modes like CBC. The posted Python code applies this design, but does not take authentication into account and therefore implements GCM incompletely. A correct example for this design can be found here.
    Cryptography generally recommends the other approach for GCM (s. the Danger note), namely the AESGCM class, which performs implicit authentication so that this cannot be accidentally forgotten or incorrectly implemented.

    The following implementation uses the AESGCM class (and also takes into account the optional additional authenticated data):

    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    import base64
    #import os
    
    #key = AESGCM.generate_key(bit_length=256)    
    #nonce = os.urandom(12)
    key = base64.b64decode('MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=') # fix for testing, AES-256
    nonce = base64.b64decode('MDEyMzQ1Njc4OTAx') # fix for testing, 12 bytes
    
    plaintext = b'The quick brown fox jumps over the lazy dog'
    aad = b'the aad' # aad = None without additional authenticated data
    
    aesgcm = AESGCM(key)
    ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
    print('Ciphertext (B64): ' + base64.b64encode(ciphertext).decode('utf8'))
    decrypted = aesgcm.decrypt(nonce, ciphertext, aad)
    print('Decrypted:        ' + decrypted.decode('utf8'))
    

    with the output:

    Output
    Ciphertext (B64): JOetStCANhPISvQ6G6IcRBauqbtC8fzRooblayHqkqSPKzLbidx/gBWfLNzBC+ZpcAGnGnHXaI7CB1U=
    Decrypted:        The quick brown fox jumps over the lazy dog
    

    The authentication tag is appended to the ciphertext, so the (Base64 decoded) result has the length of the plaintext (43 bytes) plus the length of the tag (16 bytes, default), giving a total of 59 bytes.

    For testing, a predefined key and IV/nonce are used with regard to a comparison with the result of the JavaScript code. Note that in practice a key/IV pair may only be used once for security reasons, which is especially important for GCM mode, e.g. here. Therefore a random IV/nonce is typically generated for each encryption.


    The WebCrypto API is a low level API for cryptography and does not provide methods for Base64 encoding/decoding. In the following, js-base64 is used for simplicity. Just like the Python code, the tag is appended to the ciphertext.

    A possible implementation for AES-GCM using the key and IV/nonce of the Python code that is functionally essentially the same as the posted JavaScript code is:

    (async () => {      
        var key = Base64.toUint8Array('MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE='); // fix for testing, AES-256
        var nonce = Base64.toUint8Array('MDEyMzQ1Njc4OTAx'); // fix for testing, 12 bytes
    
        var plaintext = new TextEncoder().encode("The quick brown fox jumps over the lazy dog");
        var aad = new TextEncoder().encode('the aad');
                    
        var keyImported = await await crypto.subtle.importKey(
            "raw",
            key,
            { name: "AES-GCM" },
            true,
            ["decrypt", "encrypt"]
        );
                    
        var ciphertext = await await crypto.subtle.encrypt(
            { name: "AES-GCM", iv: nonce, additionalData: aad }, // { name: "AES-GCM", iv: nonce } without additional authenticated data
            keyImported,
            plaintext
        );
        console.log('Ciphertext (Base64):\n', Base64.fromUint8Array(new Uint8Array(ciphertext)).replace(/(.{48})/g,'$1\n'));
                  
        var decrypted = await await crypto.subtle.decrypt(
            { name: "AES-GCM", iv: nonce, additionalData: aad }, // { name: "AES-GCM", iv: nonce } without additional authenticated data
            keyImported,
            ciphertext
        );
        console.log('Plaintext:\n', new TextDecoder().decode(decrypted).replace(/(.{48})/g,'$1\n'));
    })();
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/base64.min.js"></script>

    with the output:

    Ciphertext (Base64):
     JOetStCANhPISvQ6G6IcRBauqbtC8fzRooblayHqkqSPKzLbidx/gBWfLNzBC+ZpcAGnGnHXaI7CB1U=
    Plaintext:
     The quick brown fox jumps over the lazy dog
    

    where the ciphertext is the same as that of the Python code.