Skip to main content

using golang's crypto/aes and crypto/cipher packages

·5 mins

In an upcoming side project, I needed to encrypt and decrypt values stored in a SQLite database. This article offers an introductory overview of encryption and decryption practices but is not intended as a comprehensive guide. Encryption is a complex field that requires careful implementation to ensure data security. While searching for resources on this topic, I encountered inconsistencies and issues with the examples provided, prompting me to document what I learned for future reference.

For this example we will use the crypto/aes, crypto/cipher, and encoding/base64 Golang packages to handle encrypting/decrypting and encoding/decoding our secrets.

Encrypting #

In cryptography, encryption is the process of concealing information by converting it into a coded format that can only be decoded and read by someone possessing the correct key. This practice, dating back to ancient civilizations such as Greece and Rome, employed various methods to secure communications. Today, encryption remains a critical component of modern software development, where implementing it correctly is essential to prevent the accidental leakage of sensitive information.

To encrypt in Go, we will use multiple packages from the crypto package, specifically the crypto/aes and crypto/cipher packages.

To begin with, a secret key is essential for encryption. In this post, we will focus on symmetric key encryption, though an alternative method involves the use of public-key encryption which we might explore in a future post. For this example, I used a random string generator to generate a 32-byte-long string from random.org/strings. In a production environment, this would likely be configured outside of our application and passed in, but for our simple example, we will hard code this value.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/base64"
	"io"
	"fmt"
)

func Encrypt(secret, value string) (string, error) {
	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return "", err
	}

	plainText := []byte(value)

	// The IV needs to be unique, but not secure. Therefore it's common to
	// include it at the beginning of the ciphertext.
	ciphertext := make([]byte, aes.BlockSize+len(plainText))
	iv := ciphertext[:aes.BlockSize]
	if _, err := io.ReadFull(rand.Reader, iv); err != nil {
		return "", err
	}

	stream := cipher.NewCFBEncrypter(block, iv)
	stream.XORKeyStream(ciphertext[aes.BlockSize:], plainText)

	return base64.RawStdEncoding.EncodeToString(ciphertext), nil
}

func main() {
	in := "Hello World"
	secret := "uMnkGpBn9q4nnwQws8NSRhpXpFdQDBXg"

	encrypted, err := Encrypt(secret, in)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Encrypted: %s\n", encrypted)
}

If you execute the command go run main.go from the example above, the output will be something like Encrypted: 2PK2Q/Lj8OTU2j+Ak3qjaHiPJX8KMamxtIO2, with the encrypted value varying with each run.

Let’s delve deeper into what happens inside the Encrypt function. First, we create a cipher.Block by calling aes.NewCipher and providing it with our secret key. This cipher.Block enables us to encrypt and decrypt input. The length of our secret key determines the AES encryption standard used: 16, 24, or 32 bytes for AES-128, AES-192, or AES-256, respectively.

Next, we need to determine where to store our encrypted input, also known as ciphertext. Relying solely on our secret key for encryption is insufficient; we also require an element of randomness. This is where the initialization vector (IV) comes into play. Typically, the IV must be random or pseudorandom, although in some cases, it only needs to be unpredictable or unique.

At this point we have everything we need to encrypt our input. This is accomplished using a cipher.Stream, which is created by calling cipher.NewCFBEncrypter with our cipher.Block and the initialization vector (IV). The cipher.Stream includes the XORKeyStream function, which takes a sequence of bytes (our input data) and mixes it with a sequence from the cipher’s key stream. This key stream is generated using the secret key. The XORKeyStream function effectively scrambles the input data by applying the XOR operation between the data bytes and the key stream bytes.

Once we have our encrypted string, the final step is to base64 encode it. This encoding allows us to safely store the values or transmit them via HTTP, ensuring compatibility and preventing data corruption during transfer.

Decrypting #

Now that we have encryption set up, the next step is decrypting, which mirrors the encryption process but in reverse. First, we decode the base64-encoded ciphertext to retrieve the binary data. Next, we initialise our cipher.Block and extract the initialization vector (IV) from the start of the ciphertext. The remaining part is the actual encrypted data. The IV and cipher.Block are then used to create a cipher.NewCFBDecrypter, which provides a cipher.Stream. This stream performs the reverse XOR operation that was used during encryption, effectively decrypting the data.

package main

import (
	"crypto/aes"
	"crypto/cipher"
	"encoding/base64"
	"errors"
	"fmt"
)

func Decrypt(secret, value string) (string, error) {
	ciphertext, err := base64.RawStdEncoding.DecodeString(value)
	if err != nil {
		return "", fmt.Errorf("decoding base64: %w", err)
	}

	block, err := aes.NewCipher([]byte(secret))
	if err != nil {
		return "", err
	}

	// The IV needs to be unique, but not secure. Therefore it's common to
	// include it at the beginning of the ciphertext.
	if len(ciphertext) < aes.BlockSize {
		return "", errors.New("ciphertext too short")
	}
	iv := ciphertext[:aes.BlockSize]
	ciphertext = ciphertext[aes.BlockSize:]

	stream := cipher.NewCFBDecrypter(block, iv)

	// XORKeyStream can work in-place if the two arguments are the same.
	stream.XORKeyStream(ciphertext, ciphertext)

	return string(ciphertext), nil
}

func main() {
	in := "p6e1TZTF7i50N4Q/9CjvdKDG36ZemI6nZC70"
	secret := "uMnkGpBn9q4nnwQws8NSRhpXpFdQDBXg"

	decrypted, err := Decrypt(secret, in)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Decrypted: %s\n", decrypted)
}

An interesting difference in the decryption process compared to encryption is that when calling the stream.XORKeyStream function, we pass the ciphertext variable as both arguments. This technique is used because XORKeyStream can modify the input data in-place, turning encrypted data back into plaintext.

Conclusion #

The encryption and decryption processes are crucial for secure data handling in modern applications. By encrypting data, we transform sensitive information into a format that can be safely transmitted or stored. The subsequent decryption ensures that this data can be accurately and safely recovered by authorised parties. Utilising encoding schemes like base64 can further enhance the compatibility and manageability of encrypted data across different systems and environments. Employing these methods will be vital for the side project I am working on, and I am confident that this functionality can also be utilised in my day-to-day work in the future. In follow-up articles, I plan to explore public-key encryption and its potential uses.