Search code examples
javascriptpythonencryptioncryptographylibsodium

Decrypting a Chacha20-Poly1305 string without using tag\mac


I am able to successfully encrypt and decrypt a string using Chacha20-Poly1305 in python without using the tag (or mac) as follows (using pycryptodome library):

from Crypto.Cipher import ChaCha20_Poly1305

key = '20d821e770a6d3e4fc171fd3a437c7841d58463cb1bc7f7cce6b4225ae1dd900' #random generated key
nonce = '18c02beda4f8b22aa782444a' #random generated nonce

def decode(ciphertext):
    cipher = ChaCha20_Poly1305.new(key=bytes.fromhex(key), nonce=bytes.fromhex(nonce))
    plaintext = cipher.decrypt(bytes.fromhex(ciphertext))
    return plaintext.decode('utf-8')


def encode(plaintext):
    cipher = ChaCha20_Poly1305.new(key=bytes.fromhex(key), nonce=bytes.fromhex(nonce))
    ciphertext = cipher.encrypt(plaintext.encode('utf-8'))
    return ciphertext.hex()

encrypted = encode('abcdefg123')
print(encrypted)

# will print: ab6cf9f9e0cf73833194

print(decode(encrypted))

# will print: abcdefg123

However, taking this to Javascript, I cannot find a library that will decrypt this without requiring the mac (that I would have gotten if I had used encrypt_and_digest()).

I tried virtually every library I could find (Npm, as this would be used in a React application), they all require the mac part for the decryption. for example: libsodium-wrappers, js-chacha20 etc.

How can I overcome this?

P.S. I know it is less safe to not use the mac part, this is for educational purposes.

=== EDIT ===

  1. The string was encrypted with Chacha20-Poly1305 and its encrypted output is already given. It cannot be re-encrypted using a different algorithm.

  2. I cannot encrypt using Chacha20-Poly1305 and decrypt with Chacha20 or vice versa because encrypting with the same key and nonce using only Chacha20 (rather than Chacha20-Poly1305) gives me a different encrypted output and this it is not helping.


Solution

  • The issue is caused by different values for the initial counter regarding encryption/decryption in the libraries applied.

    Background: ChaCha20 is operated in counter mode to derive a key stream that is XORed with the plaintext. Thereby an (increment-by-one) counter counts through a sequence of input blocks for the ChaCha20 block function. The initial counter is the start value of that counter. For more details on ChaCha20, see here and RFC 8439.


    A similar situation regarding different initial counters can be found in the PyCryptodome library itself:

    With ChaCha20-Poly1305, the counter 0 is used to determine the authentication tag, so the counter 1 is the first value applied for encryption, s. here.

    PyCryptodome follows this logic within the ChaCha20_Poly1305 implementation for encrypt() as well, to ensure that encrypt() and digest() give the same result as encrypt_and_digest().
    However, within the ChaCha20 implementation, the initial counter for encryption is 0 by default (and not 1).

    Therefore, to allow decryption of the ciphertext generated with ChaCha20_Poly1305#encrypt() using ChaCha205#decrypt(), the counter must be explicitly set to 1 for the latter.
    This can be achieved with the seek() method (note that seek() requires the position in bytes; since a ChaCha20 block is 64 bytes in size, the counter value 1 corresponds to byte index 64 in the key stream):

    from Crypto.Cipher import ChaCha20, ChaCha20_Poly1305
    
    key = '20d821e770a6d3e4fc171fd3a437c7841d58463cb1bc7f7cce6b4225ae1dd900' # 32 bytes key
    nonce = '18c02beda4f8b22aa782444a' # 12 bytes nonce
    plaintext = 'abcdefg123'
    
    # Encrypt with ChaCha20_Poly1305#encrypt()
    cipher = ChaCha20_Poly1305.new(key=bytes.fromhex(key), nonce=bytes.fromhex(nonce))
    ciphertext = cipher.encrypt(plaintext.encode('utf-8'))
    print(ciphertext.hex()) # ab6cf9f9e0cf73833194
    
    # Decrypt with ChaCha20#decrypt()
    decipher = ChaCha20.new(key=bytes.fromhex(key), nonce=bytes.fromhex(nonce))  
    decipher.seek(64) # set counter to 1 (seek() requires the position in bytes, a ChaCha20 block is 64 bytes)
    decrypted = decipher.decrypt(ciphertext)  
    print(decrypted.decode('utf-8')) # ab6cf9f9e0cf73833194
    

    The same applies to js-chacha20, a ChaCha20 implementation for JavaScript. By default, 0 is applied as the initial counter for encryption, s. here. Thus, to be compatible with ciphertexts created with PyCryptodome's ChaCha20_Poly1305#encrypt(), the initial counter must be explicitly set to 1:

    const key = hex2ab("20d821e770a6d3e4fc171fd3a437c7841d58463cb1bc7f7cce6b4225ae1dd900"); // 32 bytes key
    const nonce = hex2ab("18c02beda4f8b22aa782444a"); // 12 bytes nonce
    const ciphertext = hex2ab("ab6cf9f9e0cf73833194"); // ciphertext from PyCryptodome
    
    const decryptor = new JSChaCha20(key, nonce, 1); // Fix: set initial counter = 1 in the 3rd parameter
    const plaintext = decryptor.decrypt(ciphertext);
    console.log(new TextDecoder().decode(plaintext)); // abcdefg123
    
    function hex2ab(hex){
        return new Uint8Array(hex.match(/[\da-f]{2}/gi).map(function (h) {return parseInt(h, 16)}));
    }
    <script src=" https://cdn.jsdelivr.net/npm/[email protected]/src/jschacha20.min.js "></script>

    With this change, the ciphertext is decrypted correctly.


    For completeness: The ChaCha20 specification allows any value for the initial counter and explicitly lists the values 1 (e.g. in the context of an AEAD algorithm) and 0 as the usual values.
    In chapter 2.4. The ChaCha20 Encryption Algorithm of RFC 8439, ChaCha20 and Poly1305 for IETF Protocols it states regarding the counter:

    A 32-bit initial counter. This can be set to any number, but will
    usually be zero or one. It makes sense to use one if we use the
    zero block for something else, such as generating a one-time
    authenticator key as part of an AEAD algorithm.