2

I've Encrypted my text by a key in Client by AES-256-GCM algorithm and I can decrypt it in Client, But when I send it to the Backend which has a SharedKey(the same as the Client has), it can decrypt the message by AES-256-CTR algorithm(I used this algo because the AES-256-GCM in Nodejs needs authTag that I don't create it in Client and iv is the only thing I have).

When I decrypt the message on the Backend side, it works with no error, but the result is not what I encrypted in the Client

Here is what I wrote: Client:

async function encrypt(text: string) {
    const encodedText = new TextEncoder().encode(text);

    const aesKey = await generateAesKey();
    const iv = window.crypto.getRandomValues(
      new Uint8Array(SERVER_ENCRYPTION_IV_LENGTH)
    );

    const encrypted = await window.crypto.subtle.encrypt(
      {
        name: 'AES-GCM',
        iv,
      },
      aesKey,
      encodedText
    );

    const concatenatedData = new Uint8Array(
      iv.byteLength + encrypted.byteLength
    );
    concatenatedData.set(iv);
    concatenatedData.set(new Uint8Array(encrypted), iv.byteLength);

    return arrayBufferToBase64(concatenatedData),
  }

Backend:

export function decrypt(sharedKey: string, message: string) {
  const messageBuffer = new Uint8Array(base64ToArrayBuffer(message));
  const iv = messageBuffer.subarray(0, 16);
  const data = messageBuffer.subarray(16);

  const decipher = crypto.createDecipheriv(
    'aes-256-ctr',
    Buffer.from(sharedKey, 'base64'),
    iv
  );

  const decrypted =
    decipher.update(data, 'binary', 'hex') + decipher.final('hex');

  return Buffer.from(decrypted, 'hex').toString('base64');
}

Sample usage:

const encrypted = encrypt("Hi Everybody");

// send the encrypted message to the server

// Response is: Ô\tp\x8F\x03$\f\x91m\x8B B\x1CkQPQ=\x85\x97\x8AêsÌG0¸Ê
Ali Torki
  • 1,929
  • 16
  • 26
  • It seems that you have located the bug from your other (now deleted) question in the symmetric encryption part. I had noticed before the deletion that in the ECDH part there is another bug in the key conversion when importing the public key on the server side. Possibly you have figured out the problem yourself. Otherwise you can post the ECDH part and the calculation of the shared secret and AES key in a new question. – Topaco Sep 11 '21 at 07:53

1 Answers1

4

Since GCM is based on CTR, decryption with CTR is in principle also possible. However, this should generally not be done in practice, since it skips the authentication of the ciphertext, which is the added value of GCM over CTR. The correct way is to decrypt on the NodeJS side with GCM and properly consider the authentication tag.
The authentication tag is automatically appended to the ciphertext by the WebCrypto API, while the crypto module of NodeJS handles ciphertext and tag separately. Therefore, not only the nonce but also the authentication tag must be separated on the NodeJS side.

The following JavaScript/WebCrypto code demonstrates the encryption:

(async () => {
    var nonce = crypto.getRandomValues(new Uint8Array(12));

    var plaintext = 'The quick brown fox jumps over the lazy dog';
    var plaintextEncoded = new TextEncoder().encode(plaintext);

    var aesKey = base64ToArrayBuffer('a068Sk+PXECrysAIN+fEGDzMQ3xlpWgE1bWXHVLb0AQ=');   
    var aesCryptoKey = await crypto.subtle.importKey('raw', aesKey, 'AES-GCM', true, ['encrypt', 'decrypt']);
    
    var ciphertextTag = await crypto.subtle.encrypt({name: 'AES-GCM', iv: nonce}, aesCryptoKey, plaintextEncoded);
    ciphertextTag = new Uint8Array(ciphertextTag);
    
    var nonceCiphertextTag = new Uint8Array(nonce.length + ciphertextTag.length);
    nonceCiphertextTag.set(nonce);
    nonceCiphertextTag.set(ciphertextTag, nonce.length);
    
    nonceCiphertextTag = arrayBufferToBase64(nonceCiphertextTag.buffer);
    document.getElementById("nonceCiphertextTag").innerHTML = nonceCiphertextTag; // ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=
})();

// Helper

// https://stackoverflow.com/a/9458996/9014097
function arrayBufferToBase64(buffer){
    var binary = '';
    var bytes = new Uint8Array(buffer);
    var len = bytes.byteLength;
    for (var i = 0; i < len; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return window.btoa(binary);
}

// https://stackoverflow.com/a/21797381/9014097
function base64ToArrayBuffer(base64) {
    var binary_string = window.atob(base64);
    var len = binary_string.length;
    var bytes = new Uint8Array(len);
    for (var i = 0; i < len; i++) {
        bytes[i] = binary_string.charCodeAt(i);
    }
    return bytes.buffer;
}
<p style="font-family:'Courier New', monospace;" id="nonceCiphertextTag"></p>

This code is basically the same as your code, with some changes needed because of methods you didn't post like generateAesKey() or arrayBufferToBase64().

Example output:

ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=

The following NodeJS/crypto code demonstrates the decryption. Note the tag separation and explicit passing with setAuthTag():

var crypto = require('crypto');

function decrypt(key, nonceCiphertextTag) {

    key = Buffer.from(key, 'base64');
    nonceCiphertextTag = Buffer.from(nonceCiphertextTag, 'base64');
    var nonce = nonceCiphertextTag.slice(0, 12);
    var ciphertext = nonceCiphertextTag.slice(12, -16);
    var tag = nonceCiphertextTag.slice(-16);  // Separate tag!
 
    var decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce); 
    decipher.setAuthTag(tag); // Set tag!
    var decrypted = decipher.update(ciphertext, '', 'utf8') + decipher.final('utf8');

    return decrypted;
}

var nonceCiphertextTag = 'ihAdhr6595oyQ3koj52cnZp7VeB1fzWuY1v7vqFdSQGxK0VQxIXUegB1mVG4rC5Aymij7bQ9rmnFWbpo7C2znN4ROnnChB0=';
var key = 'a068Sk+PXECrysAIN+fEGDzMQ3xlpWgE1bWXHVLb0AQ=';
var decrypted = decrypt(key, nonceCiphertextTag);
console.log(decrypted);

Output:

The quick brown fox jumps over the lazy dog

For completeness: Decryption of a GCM ciphertext with CTR is also possible by appending 4 bytes to the 12 bytes nonce (0x00000002). For other nonce sizes the relation is more complex, see e.g. Relationship between AES GCM and AES CTR. However, as already said, this should not be done in practice, since it bypasses the authentication of the ciphertext and is thus insecure.

Topaco
  • 40,594
  • 4
  • 35
  • 62
  • Wow, it worked. May you help me in the encryption phase on the backend side? Should I create another question for that? – Ali Torki Sep 11 '21 at 11:23
  • If I encrypt a Text in the Client and send it to the Backend, it can decrypt it, but if I encrypt a message in the Backend, the Client can't decrypt it. – Ali Torki Sep 11 '21 at 11:31
  • @AliTorki - It is not usual on SO to expand a question as much as you did after it has already been answered. It often clutters up the post and makes it difficult for subsequent readers to understand the problem. So please roll your question back to the previous state and put your new question in a new post. If necessary, you can link to this post. Thank you. – Topaco Sep 11 '21 at 11:51
  • You're right, I asked another question and Thank you for your help, Thank you so much: https://stackoverflow.com/questions/69142812/nodejs-aes-256-gcm-encryption-and-decryption-in-client-by-browser-webcrypto-api – Ali Torki Sep 11 '21 at 12:01
  • I encounter this error sometimes while decryption on Backend-side but sometimes it works: Error: Unsupported state or unable to authenticate data – Ali Torki Sep 11 '21 at 15:07
  • 1
    @AliTorki - I cannot reproduce this problem even with longer and more complex plaintexts. It would be helpful if you could provide data to reconstruct the problem: Plaintext, ciphertext and key. You should also check whether the _unmodified_ ciphertext and the _same_ key are applied on the decrypting side. – Topaco Sep 11 '21 at 15:34
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/237008/discussion-between-ali-torki-and-topaco). – Ali Torki Sep 11 '21 at 17:53