AES-GCM encryption in JavaScript, decrypted in C#

AES-GCM encryption in JavaScript, decrypted in C#
Page content

If you must encrypt in the browser, JavaScript with Web Crypto API is an option. If you need to decrypt the results in a different language, it takes some time to find out how. Here’s one approach with AES-GCM.

Introduction

Crypto is complex - there is always a lot to consider. Choosing the right technology for your current purpose can be challenging and requires a lot of know-how and experience in nearly every case.

Disclaimer: This article neither claims to show the right, nor the only way to do encryption across technology boundaries. It is just an example that should help you to understand one possible approach - you may adopt it to your needs.

Encryption in JavaScript - Web Crypto API

The Web Crypto API is very powerful and fast enough to perform realtime file-encryption in the browser. However, in this example we are going to use symmetric text message encryption using Galois/Counter Mode (GCM) including a Nonce (also called IV) and a Tag for verification. Let’s dive right into the code:

// generates a 128 bit key for encryption and decryption in base64 format
const generateKey = async () => {
  const key = await window.crypto.subtle.generateKey({
            name: 'AES-GCM',
            length: 128
          },
          true, [
            'encrypt',
            'decrypt'
          ]);
  const exportedKey = await window.crypto.subtle.exportKey(
          'raw',
          key,
  );
  return bufferToBase64(exportedKey);
}

// arrayBuffer to base64
const bufferToBase64 = (arrayBuffer) => {
  return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}

// load a base64 encoded key
const loadKey = async (base64Key) => {
  return await window.crypto.subtle.importKey(
          'raw',
          base64ToBuffer(base64Key),
          "AES-GCM",
          true, [
            "encrypt",
            "decrypt"
          ]
  );
}

// base64 to arrayBuffer
const base64ToBuffer = (base64) => {
  const binary_string = window.atob(base64);
  const len = binary_string.length;
  let bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binary_string.charCodeAt(i);
  }
  return bytes.buffer;
}

const cryptGcm = async (base64Key, bytes) => {
  const key = await loadKey(base64Key);
  const iv = window.crypto.getRandomValues(new Uint8Array(12));
  const algorithm = {
    iv,
    name: 'AES-GCM'
  };
  const cipherData = await window.crypto.subtle.encrypt(
          algorithm,
          key,
          bytes
  );

  // prepend the random IV bytes to raw cipherdata
  const cipherText = concatArrayBuffers(iv.buffer, cipherData);
  return bufferToBase64(cipherText);
}

// concatenate two array buffers
const concatArrayBuffers = (buffer1, buffer2) => {
  let tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength);
  tmp.set(new Uint8Array(buffer1), 0);
  tmp.set(new Uint8Array(buffer2), buffer1.byteLength);
  return tmp.buffer;
}

const plaintext = "This is my message";
const plaintextBytes = (new TextEncoder()).encode(plaintext, 'utf-8');
const encryptionKey = await generateKey();
const ciphertext = await cryptGcm(encryptionKey, plaintextBytes);


console.log("plaintext: ", plaintext);
console.log("encryptionKey (base64):", encryptionKey);
console.log("ciphertext (base64):", ciphertext);

What is happening?

  • First, generate a random key (for convenient storage as base64 string)
    • This key needs to be safely stored for encryption / decryption
  • Then encrypt the plaintext with this key by
    • Generating a random 12 byte IV (also called nonce)
    • Encode the text message in UTF-8
    • Encrypt the plaintext with GCM
    • Prepend the random IV bytes (this step is arbitrary, you could also store the IV in another way)
    • base64 encode the whole byte stream

Decryption in C#

Now that you have stored your randomly generated key and the ciphertext, you can decrypt the whole thing with C#:

using System;
using System.Security.Cryptography;
using System.Text;

namespace Decrypter
{
    class Program
    {
        static void Main(string[] args)
        {
            var key = "<put your base64 key here>";
            var ciphertext = "<put your base64 message here>";
        
            var plaintext = Decrypt(key, ciphertext);
        
            Console.WriteLine(plaintext);
        }
    
        private static string Decrypt(string base64Key, string base64Ciphertext)
        {

            // convert from base64 to raw bytes spans
            var encryptedData = Convert.FromBase64String(base64Ciphertext).AsSpan();
            var key = Convert.FromBase64String(base64Key).AsSpan();

            var tagSizeBytes = 16; // 128 bit encryption / 8 bit = 16 bytes
            var ivSizeBytes = 12; // 12 bytes iv
       
            // ciphertext size is whole data - iv - tag
            var cipherSize = encryptedData.Length - tagSizeBytes - ivSizeBytes;

            // extract iv (nonce) 12 bytes prefix
            var iv = encryptedData.Slice(0, ivSizeBytes);
        
            // followed by the real ciphertext
            var cipherBytes = encryptedData.Slice(ivSizeBytes, cipherSize);

            // followed by the tag (trailer)
            var tagStart = ivSizeBytes + cipherSize;
            var tag = encryptedData.Slice(tagStart);

            // now that we have all the parts, the decryption
            Span<byte> plainBytes = cipherSize < 1024
                ? stackalloc byte[cipherSize]
                : new byte[cipherSize];
            using var aes = new AesGcm(key);
            aes.Decrypt(iv, cipherBytes, tag, plainBytes);
            return Encoding.UTF8.GetString(plainBytes);
        }
    }
}

Conclusion

You see that it is totally possible to encrypt in JavaScript and decrypt in other langauges (e.g. C#). The clue is to find out where the different parts of the encryption headers and trailers are being stored by different technologies (IV/Nonce, Tag, CipherData) - often you have to adjust or add them manually.

Hints:

  • Storing the IV (nonce) as prefix of the ciphertext is arbitrary - you could also define a custom header with length or store it as a trailer, but the IV must be somehow transmitted with the ciphertext. The extraction in C# has to match the process of adding and vice versa.
  • Encrypting raw binary bytes would work similar, but you have to adjust some lines if you don’t want to encrypt text messages

Hope I could save you some time figuring out how to do this.

Andreas Fuhrich avatar
About Andreas Fuhrich
I’m a professional software developer and tech enthusiast from Germany. On this website I share my personal notes and side project details. If you like it, you could support me on github - if not, feel free to file an issue :-)