Search code examples
javascriptpythonencryptionaessjcl

What is the Python equivalent of this JS function to decrypt an AES-CCM-encrypted string?


I’d like to decrypt an AES-encrypted string (CCM mode) in Python 3.

The following JavaScript code which is using the sjcl library is working correctly:

const sjcl = require('sjcl');

const key = "ef530e1d82c154170296467bfe40cdb47b9ad77e685bbf8336b145dfa0e85640";
const keyArray = sjcl.codec.hex.toBits(key);
const iv = sjcl.codec.base64.fromBits(sjcl.codec.hex.toBits(key.substr(0,16))); 
const params = {
    "iv": iv,
    "v": 1,
    "iter": 1000,
    "ks": 256,
    "ts": 128,
    "mode": "ccm",
    "adata": "",
    "cipher": "aes",
    "salt": "",
};

function encrypt(data) {
    const ct = JSON.parse(sjcl.encrypt(keyArray, data, params)).ct;
    return sjcl.codec.hex.fromBits(sjcl.codec.base64.toBits(ct));
}

function decrypt(data) {
    const ct = sjcl.codec.base64.fromBits(sjcl.codec.hex.toBits(data));
    const paramsWithCt = JSON.stringify({ ...params, ...{ "ct": ct } });
    return sjcl.decrypt(keyArray, paramsWithCt);
}

let ct = encrypt("my secret string");
console.log("Cipher Text: " + ct);

let plain = decrypt(ct);
console.log("Plain Text: " + plain);

Output:

$ npm i sjcl
$ node index.js
Cipher Text: fa90bcdedbfe7ba89b69216e352a90fa57a63871fc4da7e69ab7f897f427f8e3
Plain Text: my secret string

Which library can I use to do the same in Python?

I tried using the pycryptodome library, but it accepts a different set of parameters:

  • key (bytes) – the cryptographic key
  • mode – the constant Crypto.Cipher.<algorithm>.MODE_CCM
  • nonce (bytes) – the value of the fixed nonce. It must be unique for the combination message/key. For AES, its length varies from 7 to 13 bytes. The longer the nonce, the smaller the allowed message size (with a nonce of 13 bytes, the message cannot exceed 64KB). If not present, the library creates a 11 bytes random nonce (the maximum message size is 8GB).
  • mac_len (integer) – the desired length of the MAC tag (default if not present: 16 bytes).
  • msg_len (integer) – pre-declaration of the length of the message to encipher. If not specified, encrypt() and decrypt() can only be called once.
  • assoc_len (integer) – pre-declaration of the length of the associated data. If not specified, some extra buffering will take place internally.

Solution

  • The sjcl operates on arrays of 4 byte words. With sjcl.codec.hex.toBits() the hex encoded key is converted into such an array. The first 8 bytes (16 hexdigits) of the key are used as nonce.
    Key size, tag size, algorithm and mode are determined from the params object. The params object further contains parameters for the key derivation, e.g. iter, salt, etc.), but these are ignored here since the key is passed as an array and not as a string.
    Nonce and ciphertext are passed Base64 encoded within the params object.

    The ciphertext is the concatenation of the actual ciphertext and the tag in this order, which must also be passed to the decryption in this format.
    While the sjcl processes ciphertext and tag concatenated, PyCryptodome handles both separately. Apart from that, encryption and decryption in Python is straightforward with PyCryptodome:

    from Crypto.Cipher import AES
    
    data = b'my secret string'
    key = bytes.fromhex('ef530e1d82c154170296467bfe40cdb47b9ad77e685bbf8336b145dfa0e85640')
    nonce = bytes.fromhex('ef530e1d82c154170296467bfe40cdb47b9ad77e685bbf8336b145dfa0e85640')[:8]
    
    # Encryption 
    cipher = AES.new(key, AES.MODE_CCM, nonce)
    ciphertext, tag = cipher.encrypt_and_digest(data)
    
    ciphertextTagHex = ciphertext.hex() + tag.hex()
    print(ciphertextTagHex) # fa90bcdedbfe7ba89b69216e352a90fa57a63871fc4da7e69ab7f897f427f8e3
    
    # Decryption
    ciphertextTag = bytes.fromhex(ciphertextTagHex)
    ciphertext = ciphertextTag[:-16]
    tag = ciphertextTag[-16:]
    
    cipher = AES.new(key, AES.MODE_CCM, nonce)
    try:
        decrypted = cipher.decrypt_and_verify(ciphertext, tag)
        print(decrypted.decode('utf-8')) # my secret string
    except ValueError:
        print('Decryption failed')
    

    Note that it is insecure to derive the nonce from the key. This is especially true for CCM, s. e.g. RFC4309, p. 3, last section:

    AES CCM employs counter mode for encryption. As with any stream cipher, reuse of the same IV value with the same key is catastrophic.

    Instead, the nonce should be randomly generated for each encryption. The nonce is not secret and is usually concatenated with the ciphertext at byte level, typically nonce|ciphertext|tag.