Search code examples
pythonencryptionhmacpycryptocryptojs

Decrypting AES and HMAC with PyCrypto


Having a bit of trouble getting a AES cipher text to decrypt.

In this particular scenario, I am encrypting data on the client side with Crypto-JS and decrypting it back on a python server with PyCrypto.

encrypt.js:

  var password = 'BJhtfRjKnTDTtPXUBnErKDxfkiMCOLyP';
  var data = 'mytext';

  var masterKey = CryptoJS.SHA256(password).toString();

  // Derive keys for AES and HMAC
  var length = masterKey.toString().length / 2
  var encryptionKey = masterKey.substr(0, length);
  var hmacKey = masterKey.substr(length);

  var iv = CryptoJS.lib.WordArray.random(64/8);

  var encrypted = CryptoJS.AES.encrypt(
    data,
    encryptionKey,
    {
      iv: iv,
      mode: CryptoJS.mode.CFB
    }
  );

  var concat = iv + encrypted;

  // Calculate HMAC using iv and cipher text
  var hash = CryptoJS.HmacSHA256(concat, hmacKey);

  // Put it all together
  var registrationKey = iv + encrypted + hash;

  // Encode in Base64
  var basemessage = btoa(registrationKey);

decrypt.py:

class AESCipher:
    def __init__(self, key):
        key_hash = SHA256.new(key).hexdigest()
        # Derive keys
        encryption_key = key_hash[:len(key_hash)/2]
        self.key = encryption_key            
        self.hmac_key = key_hash[len(key_hash)/2:]


    def verify_hmac(self, input_cipher, hmac_key):
        # Calculate hash using inputted key
        new_hash = HMAC.new(hmac_key, digestmod=SHA256)
        new_hash.update(input_cipher)
        digest = new_hash.hexdigest()

        # Calculate hash using derived key from local password
        local_hash = HMAC.new(self.hmac_key, digestmod=SHA256)
        local_hash.update(input_cipher)
        local_digest = local_hash.hexdigest()

        return True if digest == local_digest else False


    def decrypt(self, enc):
        enc = base64.b64decode(enc)
        iv = enc[:16]
        hmac = enc[60:]
        cipher_text = enc[16:60]

        # Verify HMAC using concatenation of iv + cipher like in js
        verified_hmac = self.verify_hmac((iv+cipher_text), self.hmac_key)

        if verified_hmac:
            cipher = AES.new(self.key, AES.MODE_CFB, iv)
            return cipher.decrypt(cipher_text)


password = 'BJhtfRjKnTDTtPXUBnErKDxfkiMCOLyP'

input = 'long base64 registrationKey...'

cipher = AESCipher(password)
decrypted = cipher.decrypt(input)

I'm successful in re-calculating the HMAC but when I try and then decrypt the cipher I get something that seems encrypted with �'s in the result.

I was getting errors about input length of cipher text but when I switched to CFB mode that fixed it so I don't think it's a padding issue.


Solution

  • There are many problems with your code.

    Client (JavaScript):

    • AES has a block size of 128 bit and CFB mode expects a full block for the IV. Use

      var iv = CryptoJS.lib.WordArray.random(128/8);
      
    • The iv and hash variables are WordArray objects, but encrypted is not. When you force them to be converted to strings by concatenating them (+), iv and hash are Hex-encoded, but encrypted is formatted in an OpenSSL compatible format and Base64-encoded. You need to access the ciphertext property to get the encrypted WordArray:

      var concat = iv + encrypted.ciphertext;
      

      and

      var registrationKey = iv + encrypted.ciphertext + hash;
      
    • registrationKey is hex-encoded. There is no need to encode it again with Base64 and bloat it even more:

      var basemessage = registrationKey;
      

      If you want to convert the hex encoded registrationKey to base64 encoding, use:

      var basemessage = CryptoJS.enc.Hex.parse(registrationKey).toString(CryptoJS.enc.Base64);
      
    • concat is a hex-encoded string of the IV and ciphertext, because you forced the stringification by "adding" (+) iv and encrypted. The HmacSHA256() function takes either a WordArray object or a string. When you pass a string in, as you do, it will assume that the data is UTF-8 encoded and try to decode it as UTF-8. You need to parse the data yourself into a WordArray:

      var hash = CryptoJS.HmacSHA256(CryptoJS.enc.Hex.parse(concat), hmacKey);
      
    • The CryptoJS.AES.encrypt() and CryptoJS.HmacSHA256() expect the key either as a WordArray object or as a string. As before, if the key is supplied as a string, a UTF-8 encoding is assumed which is not the case here. You better parse the strings into WordArrays yourself:

      var encryptionKey = CryptoJS.enc.Hex.parse(masterKey.substr(0, length));
      var hmacKey = CryptoJS.enc.Hex.parse(masterKey.substr(length));
      

    Server (Python):

    • You're not verifying anything in verify_hmac(). You hash the same data with the same key twice. What you need to do is hash the IV+ciphertext and compare the result with the hash (called tag or HMAC-tag) that you slice off the full ciphertext.

      def verify_hmac(self, input_cipher, mac):
          # Calculate hash using derived key from local password
          local_hash = HMAC.new(self.hmac_key, digestmod=SHA256)
          local_hash.update(input_cipher)
          local_digest = local_hash.digest()
      
          return mac == local_digest
      

      And later in decrypt():

      verified_hmac = self.verify_hmac((iv+cipher_text), hmac)
      
    • You need to correctly slice off the MAC. The 60 that is hardcoded is a bad idea. Since you're using SHA-256 the MAC is 32 bytes long, so you do this

      hmac = enc[-32:]
      cipher_text = enc[16:-32]
      
    • The CFB mode is actually a set of similar modes. The actual mode is determined by the segment size. CryptoJS only supports segments of 128 bit. So you need tell pycrypto to use the same mode as in CryptoJS:

      cipher = AES.new(self.key, AES.MODE_CFB, iv, segment_size=128)
      

      If you want to use CFB mode with a segment size of 8 bit (default of pycrypto), you can use a modified version of CFB in CryptoJS from my project: Extension for CryptoJS

    Full client code:

    var password = 'BJhtfRjKnTDTtPXUBnErKDxfkiMCOLyP';
    var data = 'mytext';
    
    var masterKey = CryptoJS.SHA256(password).toString();
    var length = masterKey.length / 2
    var encryptionKey = CryptoJS.enc.Hex.parse(masterKey.substr(0, length));
    var hmacKey = CryptoJS.enc.Hex.parse(masterKey.substr(length));
    
    var iv = CryptoJS.lib.WordArray.random(128/8);
    
    var encrypted = CryptoJS.AES.encrypt(
        data,
        encryptionKey,
        {
          iv: iv,
          mode: CryptoJS.mode.CFB
        }
    );
    
    var concat = iv + encrypted.ciphertext; 
    var hash = CryptoJS.HmacSHA256(CryptoJS.enc.Hex.parse(concat), hmacKey);
    var registrationKey = iv + encrypted.ciphertext + hash;
    console.log(CryptoJS.enc.Hex.parse(registrationKey).toString(CryptoJS.enc.Base64));
    

    Full server code:

    from Crypto.Cipher import AES
    from Crypto.Hash import HMAC, SHA256
    import base64
    import binascii
    
    class AESCipher:
        def __init__(self, key):
            key_hash = SHA256.new(key).hexdigest()
            self.hmac_key = binascii.unhexlify(key_hash[len(key_hash)/2:])
            self.key = binascii.unhexlify(key_hash[:len(key_hash)/2])
    
        def verify_hmac(self, input_cipher, mac):
            local_hash = HMAC.new(self.hmac_key, digestmod=SHA256)
            local_hash.update(input_cipher)
            local_digest = local_hash.digest()
    
            return SHA256.new(mac).digest() == SHA256.new(local_digest).digest() # more or less constant-time comparison
    
        def decrypt(self, enc):
            enc = base64.b64decode(enc)
            iv = enc[:16]
            hmac = enc[-32:]
            cipher_text = enc[16:-32]
    
            verified_hmac = self.verify_hmac((iv+cipher_text), hmac)
    
            if verified_hmac:
                cipher = AES.new(self.key, AES.MODE_CFB, iv, segment_size=128)
                return cipher.decrypt(cipher_text)
            else:
                return 'Bad Verify'
    
    
    password = 'BJhtfRjKnTDTtPXUBnErKDxfkiMCOLyP'
    
    input = "btu0CCFbvdYV4B/j7hezAra6Q6u6KB8n5QcyA32JFLU8QRd+jLGW0GxMQsTqxaNaNkcU2I9r1ls4QUPUpaLPQg=="
    
    obj = AESCipher(password)
    decryption = obj.decrypt(input)
    
    print 'Decrypted message:', decryption