Search code examples
node.jsgoaesaes-gcm

AES 256 GCM encryption in Node.js and decryption in Golang


In an attempt to migrate legacy code to Golang from Node.js, I am playing around with AES encryption and decryption. Below is the problem statement.

  1. We have a token obtained from AES 256 GCM encryption logic in Node.js which is being currently used almost everywhere
  2. The new service written in Go will need to use this token and extract data using AES 256 GCM decryption - which isn't working(error listed in a snippet)

I've tried to understand and replicate the requirements(nonce/initialisation vector) for decryption in Golang. Below are the code snippets I'm using. Is something wrong with code? Any help is appreciated. TIA!

Encryption code:

const crypto = require('crypto');

const createKey = secret => secret.padEnd(32, secret);
const randBytes = crypto.randomBytes(16);
const createIv = () => {
  let randStr = Buffer.from("1234567890123456").toString('base64');
  return randStr.slice(0,16);
}

const b64urlSafe = str => str.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '');
const b64urlUnsafe = str => {
  let decoded = str;
  if (decoded.length % 4 !== 0) {
    decoded += ('===').slice(0, 4 - (decoded.length % 4));
  }
  return decoded.replace(/-/g, '+').replace(/_/g, '/');
};

const defaultSecret = 'goodthingstaketimesometime123456';
const defaultKey = createKey(defaultSecret);

/**
 * Creates a cipher using AES-256-GCM
 *
 * @param {string} text the plaintext
 * @param {string} secret the secret (optional)
 * @returns a ciphertext (including an auth tag, separated by an underscore)
 */
const createCipher = function (text, secret = null) {
  const iv = createIv();
  const key = secret ? createKey(secret) : defaultKey;
  let cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
  let crypted = cipher.update(text, 'utf8', 'base64');
  crypted += cipher.final('base64');
  cipher.getAuthTag()
  return `${b64urlSafe(crypted)}.${b64urlSafe(cipher.getAuthTag().toString('base64'))}.${b64urlSafe(iv)}`;
};

Decryption code:

func decryptTokenFromNode() {
    fmt.Println("======decryptTokenFromNode function ======")
    token := "KZhf9KXZUKmH2jfhYIc68M4x/60gzx6+5aYujPI8ZYc4xaO16mVdtpOXKRjP+cPAk9ftNzFOrngll4sqK0jPYDqJkVdBv+9Kw==="
    iv := "MTIzNDU2Nzg5MDEy"
    ciphertext, _ := base64.StdEncoding.DecodeString(token)
    nonce, _ := base64.StdEncoding.DecodeString(iv)

    key := []byte("goodthingstaketimesometime123456")

    block, err := aes.NewCipher(key)
    if err != nil {
        panic(err.Error())
    }

    aesgcm, err := cipher.NewGCM(block)
    if err != nil {
        panic(err.Error())
    }

    fmt.Println("nonce length:", len(nonce))

    plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
    if err != nil {
        panic(err.Error())
    }

    fmt.Printf("%s\n", plaintext)
}

error:

panic: cipher: message authentication failed

goroutine 1 [running]:
main.decryptTokenFromNode()
    /Users/santhosh/Quizizz/auth-service/decrypt.go:120 +0x208
main.main()
    /Users/santhosh/Quizizz/auth-service/decrypt.go:128 +0x20
exit status 2

Solution

  • The ciphertext you posted could not have been generated with the NodeJS code, because the NodeJS code uses Base64url and the posted ciphertext applies standard Base64. Also, a triple = is invalid.
    If the posted ciphertext is decrypted without authentication, the plaintext can be identified as:

    encrypting this string to verify seal and open in golang
    

    and if this plaintext is re-encrypted with the NodeJS code, the result is:

    KZhf9KXZUKmH2jfhYIc68M4x_60gzx6-5aYujPI8ZYc4xaO16mVdtpOXKRjP-cPAk9ftNzFOrng.ll4sqK0jPYDqJkVdBv-9Kw.MTIzNDU2Nzg5MDEy
    

    whose first two parts are essentially the same as the ciphertext you posted (except for the "."-separator, the two different characters of the Base64 (+ and /) and Base64url (- and _) alphabets, and the incorrect padding.
    Perhaps the differences in the ciphertext you posted are due to copy/paste errors or some subsequent editing, or the posted ciphertext was simply generated with a different code.


    The ciphertext consists of three parts, which are separated by the "."-delimiter. For decryption, these three parts must initially be separated. The first part corresponds to the Base64url encoded ciphertext, the second part to the Base64url encoded authentication tag and the third part is the nonce (in contrast to the first two parts not the raw value but the Base64url encoded value is used as nonce, which is 16 bytes long).

    After separation, ciphertext and tag are to be Base64url decoded and concatenated in the order ciphertext|tag.

    This data is then used to perform decryption using AES-GCM with the following Go code:

    package main
    
    import (
        "crypto/aes"
        "crypto/cipher"
        "encoding/base64"
        "fmt"
        "strings"
    )
    
    func main() {
        decryptTokenFromNode()
    }
    
    func decryptTokenFromNode() {
        fmt.Println("======decryptTokenFromNode function ======")
    
        // Separate Base64url encoded ciphertext, tag and nonce
        token := "KZhf9KXZUKmH2jfhYIc68M4x_60gzx6-5aYujPI8ZYc4xaO16mVdtpOXKRjP-cPAk9ftNzFOrng.ll4sqK0jPYDqJkVdBv-9Kw.MTIzNDU2Nzg5MDEy"
        data := strings.Split(token, ".")
        ciphertext, _ := base64.RawURLEncoding.DecodeString(data[0])
        tag, _ := base64.RawURLEncoding.DecodeString(data[1])
        nonce := ([]byte)(data[2])
    
        // Concatenate raw cipheretext and tag
        ciphertext = append(ciphertext, tag...)
    
        key := []byte("goodthingstaketimesometime123456")
        block, err := aes.NewCipher(key)
        if err != nil {
            panic(err.Error())
        }
    
        // Import 16 bytes nonce
        aesgcm, err := cipher.NewGCMWithNonceSize(block, 16)
        if err != nil {
            panic(err.Error())
        }
    
        plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil)
        if err != nil {
            panic(err.Error())
        }
    
        fmt.Printf("%s\n", plaintext) // encrypting this string to verify seal and open in golang
    }
    

    Security:

    What is striking about the NodeJS code is the generation of the nonce. Firstly, a 16 bytes nonce is used, which is different from the recommended length of 12 bytes for GCM. For this reason, NewGCMWithNonceSize() must be used. Regarding the length, it would be advisable to use a 12 bytes nonce for compatibility and efficiency reasons.
    Secondly, a static nonce is used, which is a serious vulnerability for GCM. The correct way would be to create a random nonce for each encryption (most reasonable a 12 bytes nonce), which is Base64url encoded only for concatenation (analogously to the other portions). Maybe this is even intended and it is just a bug in your SO sample code.

    A second vulnerability is to use a string as key. Keys should be random byte sequences and not strings. If a string is to be used as key material, then a key derivation function (at least PBKDF2 or better the more modern Argon2) should be applied.