1

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
0

1 Answer 1

2

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.

Sign up to request clarification or add additional context in comments.

2 Comments

Run the Go code online here go.dev/play/p/OmsVzWrHkLG and the NodeJS Code here jdoodle.com/ia/NBi
Thanks for the answer! Agree with the comments, the nonce is randomly generated in the original code. I've made it static for debugging. Key should be corrected, point taken. Update - I figured the issue is due to base64 decoding of nonce which wasn't the case in Node.js code. And using NewGCMWithNonceSize without decoding nonce worked.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.