AES Encryption Modes: GCM, CBC, CTR, and More
Advanced Encryption Standard (AES) is a symmetric block cipher that encrypts data in 128-bit blocks. However, AES alone can only encrypt single blocks. To encrypt larger amounts of data securely, we need modes of operation that define how multiple blocks are processed. This comprehensive guide explores the most important AES modes with practical Go implementations.
Mode Comparison Table
Mode | Confidentiality | Authentication | Parallelizable | Padding | Error Propagation | Use Case |
---|---|---|---|---|---|---|
ECB | Weak | None | Yes | Required | No | Never use |
CBC | Strong | None | No | Required | Limited | Legacy systems |
CTR | Strong | None | Yes | None | No | With separate MAC |
GCM | Strong | Built-in | Yes | None | No | Recommended |
CFB | Strong | None | No | None | Limited | Streaming |
OFB | Strong | None | No | None | No | Streaming |
Understanding Block Cipher Modes
Block cipher modes determine how a block cipher like AES processes data larger than a single block (128 bits for AES). Each mode has different security properties, performance characteristics, and use cases.
Key Concepts
- Block Size: AES uses 128-bit (16-byte) blocks
- Initialization Vector (IV): Random data used to ensure identical plaintexts produce different ciphertexts
- Nonce: Number used once - similar to IV but with stricter uniqueness requirements
- Padding: Extra bytes added to make plaintext fit block boundaries
AES Key Sizes
AES supports three key sizes, each offering different levels of security:
- AES-128: 128-bit keys (16 bytes) - Fast performance, sufficient for most applications
- AES-192: 192-bit keys (24 bytes) - Enhanced security with moderate performance impact
- AES-256: 256-bit keys (32 bytes) - Maximum security, recommended for sensitive data
Larger keys provide stronger cryptographic security but with slightly lower performance due to additional encryption rounds (10 rounds for AES-128, 12 for AES-192, 14 for AES-256). For most applications, AES-256 is recommended as it provides excellent security margins against future attacks.
ECB (Electronic Codebook Mode)
ECB is the simplest mode but also the least secure. Each block is encrypted independently using the same key.
ECB encrypts each block independently: Block → AES(Key) → Cipher
Implementation
import (
"crypto/aes"
"crypto/rand"
"fmt"
)
func padPKCS7(data []byte, blockSize int) []byte {
padding := blockSize - (len(data) % blockSize)
padText := make([]byte, padding)
for i := range padText {
padText[i] = byte(padding)
}
return append(data, padText...)
}
func unpadPKCS7(data []byte) []byte {
if len(data) == 0 {
return data
}
padding := int(data[len(data)-1])
if padding > len(data) {
return data
}
return data[:len(data)-padding]
}
func encryptECB(plaintext []byte, key []byte) ([]byte, error) {
cipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
paddedText := padPKCS7(plaintext, aes.BlockSize)
ciphertext := make([]byte, len(paddedText))
for i := 0; i < len(paddedText); i += aes.BlockSize {
cipher.Encrypt(ciphertext[i:i+aes.BlockSize], paddedText[i:i+aes.BlockSize])
}
return ciphertext, nil
}
func decryptECB(ciphertext []byte, key []byte) ([]byte, error) {
cipher, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
plaintext := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += aes.BlockSize {
cipher.Decrypt(plaintext[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
}
return unpadPKCS7(plaintext), nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
encrypted, _ := encryptECB(plaintext, key)
decrypted, _ := decryptECB(encrypted, key)
Security Issues
- Pattern Leakage: Identical plaintext blocks produce identical ciphertext blocks
- No Randomization: Same plaintext always produces same ciphertext
- Vulnerable to Analysis: Patterns in data remain visible
Never use ECB for actual encryption needs.
CBC (Cipher Block Chaining)
CBC mode chains blocks together using XOR operations with the previous ciphertext block (or IV for the first block).
CBC chains blocks using XOR: (Block ⊕ Previous) → AES(Key) → Cipher
Implementation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptCBC(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
paddedText := padPKCS7(plaintext, aes.BlockSize)
iv := make([]byte, aes.BlockSize)
_, err = rand.Read(iv)
if err != nil {
return nil, err
}
mode := cipher.NewCBCEncrypter(block, iv)
ciphertext := make([]byte, len(paddedText))
mode.CryptBlocks(ciphertext, paddedText)
return append(iv, ciphertext...), nil
}
func decryptCBC(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext)
return unpadPKCS7(plaintext), nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
encrypted, _ := encryptCBC(plaintext, key)
decrypted, _ := decryptCBC(encrypted, key)
Security Properties
Advantages:
- Identical plaintext blocks produce different ciphertext blocks
- Random IV ensures same plaintext produces different ciphertext
- Self-synchronizing for decryption
Disadvantages:
- Sequential processing (cannot parallelize encryption)
- Vulnerable to padding oracle attacks
- Error propagation (one corrupted block affects next block)
- Requires padding
CTR (Counter Mode)
CTR mode turns a block cipher into a stream cipher by encrypting a counter value and XORing with plaintext.
CTR generates keystream from counters: AES(Counter, Key) ⊕ Block → Cipher
Implementation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptCTR(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
_, err = rand.Read(iv)
if err != nil {
return nil, err
}
stream := cipher.NewCTR(block, iv)
ciphertext := make([]byte, len(plaintext))
stream.XORKeyStream(ciphertext, plaintext)
return append(iv, ciphertext...), nil
}
func decryptCTR(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
stream := cipher.NewCTR(block, iv)
plaintext := make([]byte, len(ciphertext))
stream.XORKeyStream(plaintext, ciphertext)
return plaintext, nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
encrypted, _ := encryptCTR(plaintext, key)
decrypted, _ := decryptCTR(encrypted, key)
Security Properties
Advantages:
- Highly parallelizable (encrypt/decrypt blocks independently)
- No padding required
- Random access to any part of ciphertext
- Same function for encryption and decryption
- No error propagation
Considerations:
- Counter values must never repeat with same key
- Provides no authentication (use with MAC or choose authenticated mode)
GCM (Galois/Counter Mode)
GCM combines CTR mode encryption with Galois mode authentication, providing both confidentiality and authenticity.
GCM combines CTR encryption with authentication: CTR(Block) + Auth(AAD) → Cipher + Tag
Implementation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptGCM(plaintext []byte, key []byte, additionalData []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
_, err = rand.Read(nonce)
if err != nil {
return nil, err
}
ciphertext := gcm.Seal(nonce, nonce, plaintext, additionalData)
return ciphertext, nil
}
func decryptGCM(data []byte, key []byte, additionalData []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if len(data) < gcm.NonceSize() {
return nil, fmt.Errorf("ciphertext too short")
}
nonce := data[:gcm.NonceSize()]
ciphertext := data[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, additionalData)
if err != nil {
return nil, err
}
return plaintext, nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
additionalData := []byte("metadata-not-encrypted-but-authenticated")
encrypted, _ := encryptGCM(plaintext, key, additionalData)
decrypted, _ := decryptGCM(encrypted, key, additionalData)
// Authentication will fail with wrong additional data
_, err := decryptGCM(encrypted, key, []byte("wrong-metadata"))
Security Properties
Advantages:
- Provides both confidentiality and authenticity
- Highly parallelizable
- No padding required
- Additional Authenticated Data (AAD) support
- Detects tampering and forgery attempts
Best Choice: GCM is recommended for most applications requiring authenticated encryption.
CFB (Cipher Feedback Mode)
CFB mode turns a block cipher into a stream cipher where the previous ciphertext block is used as input to generate the keystream.
Implementation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptCFB(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
_, err = rand.Read(iv)
if err != nil {
return nil, err
}
stream := cipher.NewCFBEncrypter(block, iv)
ciphertext := make([]byte, len(plaintext))
stream.XORKeyStream(ciphertext, plaintext)
return append(iv, ciphertext...), nil
}
func decryptCFB(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
stream := cipher.NewCFBDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
stream.XORKeyStream(plaintext, ciphertext)
return plaintext, nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
encrypted, _ := encryptCFB(plaintext, key)
decrypted, _ := decryptCFB(encrypted, key)
OFB (Output Feedback Mode)
OFB mode generates a keystream by repeatedly encrypting the IV/previous output, then XORs with plaintext.
Implementation
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
)
func encryptOFB(plaintext []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
iv := make([]byte, aes.BlockSize)
_, err = rand.Read(iv)
if err != nil {
return nil, err
}
stream := cipher.NewOFB(block, iv)
ciphertext := make([]byte, len(plaintext))
stream.XORKeyStream(ciphertext, plaintext)
return append(iv, ciphertext...), nil
}
func decryptOFB(data []byte, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(data) < aes.BlockSize {
return nil, fmt.Errorf("ciphertext too short")
}
iv := data[:aes.BlockSize]
ciphertext := data[aes.BlockSize:]
stream := cipher.NewOFB(block, iv)
plaintext := make([]byte, len(ciphertext))
stream.XORKeyStream(plaintext, ciphertext)
return plaintext, nil
}
// Example usage
key := make([]byte, 32)
rand.Read(key)
plaintext := []byte("Hello World! This is a test message.")
encrypted, _ := encryptOFB(plaintext, key)
decrypted, _ := decryptOFB(encrypted, key)
Security Best Practices
Key Management
- Use cryptographically secure random number generators for keys
- Never reuse keys across different applications
- Implement proper key rotation
- Store keys securely (HSM, key management services)
IV/Nonce Management
- CBC/CFB/OFB: Use random IV for each encryption
- CTR: Never reuse counter values with the same key
- GCM: Use unique nonces (can be sequential or random)
Mode Selection Guidelines
- Default Choice: Use AES-GCM for new applications
- Legacy Support: Use AES-CBC with HMAC if GCM unavailable
- Streaming: Use AES-CTR with authentication
- Never Use: ECB mode under any circumstances
Performance Considerations
Benchmark Results
Test environment: M3 Pro MacBook Pro, Go 1.24.1, single thread, 1MB data blocks
AES-GCM: 6.4 GB/s (encryption + authentication)
AES-CTR: 6.1 GB/s (encryption only)
AES-ECB: 2.5 GB/s (parallel blocks, insecure)
AES-OFB: 1.2 GB/s (stream cipher)
AES-CFB: 949 MB/s (stream cipher, feedback)
AES-CBC: 872 MB/s (sequential encryption)
This comprehensive guide provides the foundation for understanding and implementing AES encryption modes securely. Always choose GCM for new projects unless you have specific requirements that necessitate other modes.