Chapter 4: Go Crypto Package - Secure Your Applications Like a Security Expert


Introduction

A developer stores user passwords in plain text. "It's just a small app," they rationalize. Six months later, the database is breached. Every user's password is exposed the same passwords many users employ for banking, email, and social media. Lives are disrupted, trust is destroyed, and legal consequences follow.
This scenario plays out with alarming frequency. In 2023 alone, billions of credentials were exposed in data breaches, many of which could have been prevented with proper cryptographic practices. The remedy is straightforward: apply cryptography correctly from the start.
Go's crypto package provides production-ready implementations of cryptographic primitives. This chapter teaches you not just how to use them, but when and why knowledge that separates secure applications from headline-making breaches.
Why every Go developer needs cryptography knowledge:
Every application handling sensitive data requires cryptographic protection:
  • User passwords: Must never be stored in recoverable form
  • API keys and secrets: Need secure generation and storage
  • Personal data: Requires encryption at rest and in transit
  • Session tokens: Must be cryptographically random
  • Data integrity: Verified through hashes and signatures
Understanding cryptography isn't optional it's fundamental to responsible software development.

Core Concepts

Go blog diagram 1

Go blog diagram 1

The Fundamental Distinction: Hashing vs Encryption

Before diving into code, understand the fundamental difference between these two operations:
Hashing is a one-way transformation. You convert data into a fixed-size fingerprint (hash). You cannot reverse this process to recover the original data. Same input always produces the same output.
  • Use case: Password storage, data integrity verification, deduplication
  • Analogy: A meat grinder you can grind steak into ground beef, but you cannot reconstruct the steak
Encryption is a two-way transformation. You convert readable data (plaintext) into unreadable data (ciphertext) using a key. With the correct key, you can reverse the process.
  • Use case: Protecting sensitive data that must be recovered
  • Analogy: A safe deposit box lock something inside with a key, and only the key can open it again

Symmetric vs Asymmetric Encryption

Within encryption, two paradigms exist:
Symmetric encryption uses the same key for encryption and decryption:
  • Fast, efficient for large data
  • Challenge: How do you securely share the key?
  • Example: AES
Asymmetric encryption uses a key pair public and private:
  • Encrypt with public key (anyone can do this)
  • Decrypt with private key (only the holder can do this)
  • Slower, typically used for key exchange or small data
  • Example: RSA, ECDSA

The Cryptographic Random Number Requirement

One fundamental rule: never use math/rand for security purposes. The math/rand package produces deterministic sequences given the same seed, you get the same "random" numbers. An attacker who knows the seed can predict every value.
Security requires cryptographically secure random numbers from crypto/rand, which draws from operating system entropy sources (hardware random number generators, system events, etc.).

Detailed Explanation: Hashing

SHA-256: The Workhorse Hash

SHA-256 (Secure Hash Algorithm, 256-bit) is the most commonly used hash function for general purposes. It produces a 32-byte (256-bit) output regardless of input size.
go
// Filename: sha256_example.go package main import ( "crypto/sha256" "encoding/hex" "fmt" ) func main() { data := "Hello, Gopher!" // Compute SHA-256 hash // Sum256 returns a [32]byte array, not a slice hash := sha256.Sum256([]byte(data)) // Convert to human-readable hexadecimal hashString := hex.EncodeToString(hash[:]) fmt.Printf("Input: %s\n", data) fmt.Printf("SHA-256: %s\n", hashString) fmt.Printf("Length: %d bytes\n", len(hash)) // Demonstrate determinism hash2 := sha256.Sum256([]byte(data)) fmt.Printf("Same hash: %v\n", hash == hash2) // Demonstrate avalanche effect (tiny change, completely different hash) hash3 := sha256.Sum256([]byte("Hello, Gopher")) // Missing '!' fmt.Printf("Different input: %s\n", hex.EncodeToString(hash3[:])) }
Key properties demonstrated:
  1. Fixed output size: Always 32 bytes, whether input is 1 byte or 1 gigabyte
  2. Deterministic: Same input always produces same output
  3. Avalanche effect: Tiny input changes cause dramatic output changes
  4. One-way: Cannot reverse the hash to find the input

Password Hashing with bcrypt

Critical rule: Never use SHA-256 directly for passwords.
SHA-256 is designed to be fast modern GPUs can compute billions of hashes per second. For passwords, this is a vulnerability: attackers can try billions of password guesses rapidly.
bcrypt is designed specifically for passwords:
  • Intentionally slow (configurable work factor)
  • Includes salt automatically (prevents rainbow table attacks)
  • Adaptive can be made slower as hardware improves
go
// Filename: password_hashing.go package main import ( "fmt" "golang.org/x/crypto/bcrypt" ) // HashPassword creates a bcrypt hash from a plaintext password. // The cost parameter controls computational expense: // - Cost 10: ~100ms on typical hardware // - Cost 12: ~400ms // - Cost 14: ~1.5s // Higher cost = more secure against brute force, but slower login. func HashPassword(password string) (string, error) { // Cost of 12 is a good balance for most applications bytes, err := bcrypt.GenerateFromPassword([]byte(password), 12) if err != nil { return "", err } return string(bytes), nil } // CheckPassword compares a plaintext password against a stored hash. // Returns true if they match, false otherwise. // This is the ONLY correct way to verify passwords never compare hashes directly. func CheckPassword(password, hash string) bool { err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) return err == nil } func main() { password := "mySecurePassword123" // Hash the password (do this once when user sets password) hash, err := HashPassword(password) if err != nil { fmt.Println("Error hashing:", err) return } fmt.Printf("Password: %s\n", password) fmt.Printf("Hash: %s\n", hash) fmt.Printf("Hash length: %d characters\n", len(hash)) // Verify correct password fmt.Printf("\nCorrect password: %v\n", CheckPassword(password, hash)) // Verify wrong password fmt.Printf("Wrong password: %v\n", CheckPassword("wrongPassword", hash)) // Demonstrate: same password produces different hashes (due to salt) hash2, _ := HashPassword(password) fmt.Printf("\nSame password, different hash: %v\n", hash != hash2) }
Why bcrypt works:
  1. Salt: Each hash includes a random salt same password produces different hashes each time. Attackers cannot precompute rainbow tables.
  2. Work factor: The 12 parameter means 2^12 = 4096 iterations internally. This makes each hash computation slow, but login is still fast enough for users.
  3. Constant-time comparison: CompareHashAndPassword uses constant-time comparison, preventing timing attacks.

Detailed Explanation: Symmetric Encryption

Go blog diagram 2

Go blog diagram 2

AES-GCM: Modern Authenticated Encryption

AES (Advanced Encryption Standard) with GCM (Galois/Counter Mode) provides both confidentiality (data is unreadable) and integrity (tampering is detected).
go
// Filename: aes_encryption.go package main import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" "io" ) // Encrypt encrypts plaintext using AES-GCM. // Key must be 16, 24, or 32 bytes (AES-128, AES-192, AES-256). // Returns base64-encoded ciphertext suitable for storage/transmission. func Encrypt(plaintext []byte, key []byte) (string, error) { // Create AES cipher block block, err := aes.NewCipher(key) if err != nil { return "", err } // Create GCM mode wrapper // GCM provides authenticated encryption both secrecy and integrity gcm, err := cipher.NewGCM(block) if err != nil { return "", err } // Generate random nonce (number used once) // Never reuse a nonce with the same key! nonce := make([]byte, gcm.NonceSize()) // 12 bytes for GCM if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } // Seal encrypts and authenticates plaintext // We prepend nonce to ciphertext for storage (nonce is not secret) ciphertext := gcm.Seal(nonce, nonce, plaintext, nil) // Base64 encode for safe storage/transmission return base64.StdEncoding.EncodeToString(ciphertext), nil } // Decrypt reverses the encryption process. func Decrypt(ciphertext string, key []byte) ([]byte, error) { // Decode from base64 data, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } // Extract nonce from the beginning of ciphertext nonceSize := gcm.NonceSize() if len(data) < nonceSize { return nil, fmt.Errorf("ciphertext too short") } nonce, encryptedData := data[:nonceSize], data[nonceSize:] // Open decrypts and verifies integrity // If tampering occurred, this returns an error plaintext, err := gcm.Open(nil, nonce, encryptedData, nil) if err != nil { return nil, err // Includes authentication failures } return plaintext, nil } func main() { // Key MUST be 32 bytes for AES-256 // In production, derive from secure source (KDF, key management system) key := []byte("my32bytesecretkey12345678901234") message := "Sensitive data that needs protection" encrypted, err := Encrypt([]byte(message), key) if err != nil { fmt.Println("Encryption error:", err) return } fmt.Printf("Original: %s\n", message) fmt.Printf("Encrypted: %s\n", encrypted) decrypted, err := Decrypt(encrypted, key) if err != nil { fmt.Println("Decryption error:", err) return } fmt.Printf("Decrypted: %s\n", string(decrypted)) }
Why AES-GCM?
  • Authenticated: If anyone modifies the ciphertext, decryption fails. No silent corruption.
  • Efficient: Hardware acceleration on modern CPUs
  • Secure: No known practical attacks when used correctly
  • Standardized: Widely reviewed, widely implemented
Critical requirements:
  1. Never reuse nonces: Same key + same nonce = broken encryption
  2. Use random nonces: Generate fresh with crypto/rand
  3. Protect the key: Encryption is only as secure as key management

Detailed Explanation: Asymmetric Encryption

Go blog diagram 3

Go blog diagram 3

RSA: Public/Private Key Encryption

RSA uses a key pair. The public key can be shared freely; the private key must remain secret.
go
// Filename: rsa_encryption.go package main import ( "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "fmt" ) func main() { // Generate RSA key pair // 2048 bits is minimum recommended; 4096 for high security privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { fmt.Println("Key generation error:", err) return } publicKey := &privateKey.PublicKey message := "Secret message for RSA encryption" // Encrypt with public key using OAEP padding // OAEP (Optimal Asymmetric Encryption Padding) is more secure than PKCS#1 v1.5 ciphertext, err := rsa.EncryptOAEP( sha256.New(), rand.Reader, publicKey, []byte(message), nil, // label (optional additional data) ) if err != nil { fmt.Println("Encryption error:", err) return } fmt.Printf("Original: %s\n", message) fmt.Printf("Encrypted: %s\n", base64.StdEncoding.EncodeToString(ciphertext)) // Decrypt with private key plaintext, err := rsa.DecryptOAEP( sha256.New(), rand.Reader, privateKey, ciphertext, nil, ) if err != nil { fmt.Println("Decryption error:", err) return } fmt.Printf("Decrypted: %s\n", string(plaintext)) }
RSA use cases:
  • Encrypting small amounts of data (keys, tokens)
  • Key exchange (encrypt a symmetric key, then use symmetric encryption for bulk data)
  • Digital signatures (sign with private key, verify with public key)
RSA limitations:
  • Slow compared to symmetric encryption
  • Message size limited to key size minus padding overhead
  • For bulk data, use hybrid encryption: RSA to encrypt an AES key, AES for the data

Detailed Explanation: Digital Signatures

Go blog diagram 4

Go blog diagram 4

Proving Authenticity

Digital signatures prove that data came from a specific sender and hasn't been modified. Unlike encryption, signatures don't hide data they verify it.
go
// Filename: digital_signature.go package main import ( "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "fmt" ) func main() { // Generate key pair for signing privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { fmt.Println("Key generation error:", err) return } message := "This document is authentic and untampered" // Sign: hash the message, then sign the hash with private key hash := sha256.Sum256([]byte(message)) signature, err := rsa.SignPKCS1v15( rand.Reader, privateKey, crypto.SHA256, hash[:], ) if err != nil { fmt.Println("Signing error:", err) return } fmt.Printf("Message: %s\n", message) fmt.Printf("Signature: %x (first 32 bytes)\n", signature[:32]) // Verify: anyone with public key can verify err = rsa.VerifyPKCS1v15( &privateKey.PublicKey, crypto.SHA256, hash[:], signature, ) if err == nil { fmt.Println("\nVerification: VALID signature") } else { fmt.Println("\nVerification: INVALID signature") } // Demonstrate tampering detection tamperedMessage := "This document has been tampered" tamperedHash := sha256.Sum256([]byte(tamperedMessage)) err = rsa.VerifyPKCS1v15( &privateKey.PublicKey, crypto.SHA256, tamperedHash[:], signature, ) if err == nil { fmt.Println("Tampered: VALID signature") } else { fmt.Println("Tampered: INVALID signature (tampering detected!)") } }
Signature properties:
  • Non-repudiation: Signer cannot deny signing (only they have the private key)
  • Integrity: Any change to message invalidates signature
  • Public verification: Anyone with public key can verify

Secure Random Number Generation

go
// Filename: secure_random.go package main import ( "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "math/big" ) // GenerateRandomBytes generates cryptographically secure random bytes. func GenerateRandomBytes(length int) ([]byte, error) { bytes := make([]byte, length) _, err := rand.Read(bytes) if err != nil { return nil, err } return bytes, nil } // GenerateToken creates a URL-safe random token. func GenerateToken(length int) (string, error) { bytes, err := GenerateRandomBytes(length) if err != nil { return "", err } return base64.URLEncoding.EncodeToString(bytes), nil } // GenerateHexToken creates a hexadecimal token. func GenerateHexToken(length int) (string, error) { bytes, err := GenerateRandomBytes(length) if err != nil { return "", err } return hex.EncodeToString(bytes), nil } // GenerateRandomInt generates a random integer in [0, max). func GenerateRandomInt(max int64) (int64, error) { n, err := rand.Int(rand.Reader, big.NewInt(max)) if err != nil { return 0, err } return n.Int64(), nil } func main() { // API key (32 bytes = 256 bits of entropy) apiKey, _ := GenerateToken(32) fmt.Printf("API Key: %s\n", apiKey) // Session token sessionToken, _ := GenerateToken(24) fmt.Printf("Session Token: %s\n", sessionToken) // Hex token (for URLs or IDs) hexToken, _ := GenerateHexToken(16) fmt.Printf("Hex Token: %s\n", hexToken) // Random number randomNum, _ := GenerateRandomInt(1000000) fmt.Printf("Random Number: %d\n", randomNum) }

HMAC: Message Authentication

HMAC (Hash-based Message Authentication Code) verifies both data integrity and authenticity using a shared secret.
go
// Filename: hmac_example.go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" ) // CreateHMAC generates an HMAC for a message. func CreateHMAC(message, secret []byte) string { h := hmac.New(sha256.New, secret) h.Write(message) return hex.EncodeToString(h.Sum(nil)) } // VerifyHMAC checks if an HMAC matches. // Uses constant-time comparison to prevent timing attacks. func VerifyHMAC(message, secret []byte, expectedMAC string) bool { actualMAC := CreateHMAC(message, secret) // hmac.Equal performs constant-time comparison return hmac.Equal([]byte(actualMAC), []byte(expectedMAC)) } func main() { secret := []byte("shared-secret-key") message := []byte("Payment of $1000 to account 12345") // Create HMAC mac := CreateHMAC(message, secret) fmt.Printf("Message: %s\n", message) fmt.Printf("HMAC: %s\n", mac) // Verify original message valid := VerifyHMAC(message, secret, mac) fmt.Printf("\nOriginal valid: %v\n", valid) // Verify tampered message tamperedMessage := []byte("Payment of $9000 to account 12345") tamperedValid := VerifyHMAC(tamperedMessage, secret, mac) fmt.Printf("Tampered valid: %v\n", tamperedValid) }

Common Mistakes and Misconceptions

Mistake 1: Using MD5 or SHA-1 for Passwords

go
// BROKEN: MD5 is cryptographically broken hash := md5.Sum([]byte(password)) // BROKEN: SHA-1 has known vulnerabilities hash := sha1.Sum([]byte(password)) // BROKEN: Even SHA-256 is too fast for passwords hash := sha256.Sum256([]byte(password)) // CORRECT: Use bcrypt, scrypt, or Argon2 hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)

Mistake 2: Hardcoding Encryption Keys

go
// BROKEN: Key visible in source code, version control, binaries key := []byte("my-secret-key-12345678901234567") // CORRECT: Load from environment or secrets manager key := []byte(os.Getenv("ENCRYPTION_KEY"))

Mistake 3: Reusing Nonces

go
// BROKEN: Same nonce for every encryption var staticNonce = []byte("static-nonce!") // CORRECT: Generate random nonce each time nonce := make([]byte, gcm.NonceSize()) rand.Read(nonce)

Mistake 4: Not Validating Key Lengths

go
// BROKEN: AES requires exactly 16, 24, or 32 bytes key := []byte("short") // Only 5 bytes! // CORRECT: Validate or derive key of correct length if len(key) != 32 { return errors.New("key must be 32 bytes") }

Summary

Key takeaways from this chapter:
  • Hashing is one-way: Use for passwords (bcrypt), integrity checks (SHA-256)
  • bcrypt for passwords: Intentionally slow, includes salt automatically
  • AES-GCM for encryption: Authenticated encryption, never reuse nonces
  • RSA for key exchange: Asymmetric, public/private key pairs
  • Digital signatures prove authenticity: Sign with private, verify with public
  • crypto/rand for security: Never use math/rand for cryptographic purposes
  • HMAC for message authentication: Combines hash with secret key
  • Constant-time comparisons: Prevent timing attacks when comparing secrets

Interview Questions

  1. Explain the difference between hashing and encryption. When would you use each?
  2. Why is bcrypt preferred over SHA-256 for password storage? What makes it more secure?
  3. What is a "salt" in password hashing? Why is it important?
  4. Explain what AES-GCM provides that AES-CBC does not. Why does authenticated encryption matter?
  5. What happens if you reuse a nonce with AES-GCM? Why is this dangerous?
  6. Describe the difference between symmetric and asymmetric encryption. What are the trade-offs?
  7. How would you securely encrypt a 1GB file using RSA? (Hint: hybrid encryption)
  8. What is the purpose of HMAC? How does it differ from a simple hash?
  9. Explain why timing attacks are possible and how constant-time comparison prevents them.
  10. A developer stores encryption keys in their source code. What are the risks? What alternatives exist?
  11. What is the purpose of OAEP padding in RSA encryption?
  12. How do digital signatures provide non-repudiation?
  13. Why must you use crypto/rand instead of math/rand for generating session tokens?
  14. Describe a scenario where you would use HMAC instead of encryption.
  15. What considerations should guide the choice of bcrypt cost parameter?
All Blogs
Tags:golangcryptographysecurityencryptionhashing