// Package crypto provides AES-256-GCM encryption for sensitive app config values. package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" "log" "os" "strings" ) const ( keySize = 32 // AES-256 prefix = "ENC:" ) // LoadOrCreateKey reads a 32-byte encryption key from path, or generates one if it doesn't exist. func LoadOrCreateKey(path string) ([]byte, error) { data, err := os.ReadFile(path) if err == nil { if len(data) != keySize { return nil, fmt.Errorf("encryption key file %s has invalid size %d (expected %d)", path, len(data), keySize) } return data, nil } if !os.IsNotExist(err) { return nil, fmt.Errorf("reading encryption key %s: %w", path, err) } // Generate new key key := make([]byte, keySize) if _, err := rand.Read(key); err != nil { return nil, fmt.Errorf("generating encryption key: %w", err) } if err := os.WriteFile(path, key, 0600); err != nil { return nil, fmt.Errorf("writing encryption key %s: %w", path, err) } return key, nil } // Encrypt encrypts plaintext with AES-256-GCM and returns "ENC:". func Encrypt(key []byte, plaintext string) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", err } ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) // nonce prepended return prefix + base64.StdEncoding.EncodeToString(ciphertext), nil } // Decrypt decrypts a value produced by Encrypt. Returns error if value is not encrypted or decryption fails. func Decrypt(key []byte, value string) (string, error) { if !IsEncrypted(value) { return "", fmt.Errorf("value is not encrypted") } data, err := base64.StdEncoding.DecodeString(value[len(prefix):]) if err != nil { return "", fmt.Errorf("base64 decode: %w", err) } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := gcm.NonceSize() if len(data) < nonceSize { return "", fmt.Errorf("ciphertext too short") } plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil) if err != nil { return "", fmt.Errorf("decrypt: %w", err) } return string(plaintext), nil } // IsEncrypted returns true if the value has the encryption prefix. func IsEncrypted(value string) bool { return strings.HasPrefix(value, prefix) } // DecryptMap decrypts all encrypted values in a map, returning a new map with plaintext values. // Logs a warning for any value that fails to decrypt (key rotation, data corruption). func DecryptMap(key []byte, env map[string]string) map[string]string { if key == nil || env == nil { return env } result := make(map[string]string, len(env)) for k, v := range env { if IsEncrypted(v) { dec, err := Decrypt(key, v) if err != nil { log.Printf("[WARN] Failed to decrypt env var %q: %v — passing through encrypted value", k, err) result[k] = v continue } result[k] = dec continue } result[k] = v } return result }