package config import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "fmt" "io" "os" "path/filepath" ) const keyFileName = "encryption.key" // keyPath returns the full path to the encryption key file. func keyPath() string { return filepath.Join(ConfigDir(), keyFileName) } // loadOrCreateKey reads the 32-byte AES key, creating it if absent. func loadOrCreateKey() ([]byte, error) { path := keyPath() data, err := os.ReadFile(path) if err == nil && len(data) == 32 { return data, nil } key := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, key); err != nil { return nil, fmt.Errorf("generate key: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("create config dir: %w", err) } if err := os.WriteFile(path, key, 0o600); err != nil { return nil, fmt.Errorf("write key file: %w", err) } return key, nil } // Encrypt encrypts plaintext using AES-256-GCM and returns a base64 string. func Encrypt(plaintext string) (string, error) { if plaintext == "" { return "", nil } key, err := loadOrCreateKey() if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("new gcm: %w", err) } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", fmt.Errorf("generate nonce: %w", err) } ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return base64.StdEncoding.EncodeToString(ciphertext), nil } // Decrypt decrypts a base64 AES-256-GCM ciphertext back to plaintext. func Decrypt(encoded string) (string, error) { if encoded == "" { return "", nil } key, err := loadOrCreateKey() if err != nil { return "", err } ciphertext, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return "", fmt.Errorf("decode base64: %w", err) } block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("new gcm: %w", err) } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { return "", fmt.Errorf("ciphertext too short") } nonce, ct := ciphertext[:nonceSize], ciphertext[nonceSize:] plaintext, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", fmt.Errorf("decrypt: %w", err) } return string(plaintext), nil }