Files
gochat/internal/security/encryption.go
T
2026-06-04 15:44:48 +08:00

253 lines
8.2 KiB
Go

package security
// Reference: P14 Deliverable #4 — Data Encryption for Sensitive Fields
// AES-256-GCM encryption/decryption for channel tokens, API keys, webhook secrets.
// Chatwoot stores these in plaintext in the database; GoChat must encrypt them.
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
applogger "github.com/gochat/gochat/pkg/logger"
)
// --- Security Audit Findings ---
//
// 1. CRITICAL: Channel tokens, API keys, and webhook secrets stored as plaintext
// in PostgreSQL. Chatwoot does the same, but for GoChat we must encrypt these
// at rest to prevent data exposure if the database is compromised.
//
// 2. HIGH: No key rotation mechanism. If the encryption key is compromised,
// all encrypted data must be re-encrypted with a new key.
// EncryptionConfig supports keyVersion for future rotation.
//
// 3. MEDIUM: AES-CBC would be simpler but lacks built-in authentication.
// AES-256-GCM provides both encryption and integrity verification,
// preventing tampering attacks (chosen ciphertext attacks).
// SensitiveFieldType identifies the type of sensitive field being encrypted.
// Different field types may have different encryption policies or key versions.
type SensitiveFieldType string
const (
FieldTypeChannelToken SensitiveFieldType = "channel_token"
FieldTypeAPIKey SensitiveFieldType = "api_key"
FieldTypeWebhookSecret SensitiveFieldType = "webhook_secret"
FieldTypeAccessToken SensitiveFieldType = "access_token"
FieldTypeRefreshToken SensitiveFieldType = "refresh_token"
FieldTypeOAuthClientSecret SensitiveFieldType = "oauth_client_secret"
)
// EncryptionConfig holds AES-256-GCM encryption configuration.
type EncryptionConfig struct {
// AESKey is the 32-byte (256-bit) encryption key, base64-encoded in config.
// Must be exactly 32 bytes after decoding for AES-256.
AESKey string
// KeyVersion tracks the current key version for rotation.
// Encrypted values are prefixed with "enc:v{version}:" to identify
// which key was used, enabling seamless key rotation.
KeyVersion int
// Enabled controls whether encryption is active.
// Set to false for development/testing; must be true in production.
Enabled bool
}
// DefaultEncryptionConfig returns a config with encryption disabled
// and an empty key. Useful for development environments.
func DefaultEncryptionConfig() EncryptionConfig {
return EncryptionConfig{
AESKey: "",
KeyVersion: 1,
Enabled: false,
}
}
// Encryptor provides AES-256-GCM encryption and decryption operations.
type Encryptor struct {
aead cipher.AEAD
keyVersion int
enabled bool
}
// NewEncryptor creates a new Encryptor from the given config.
// Returns error if the key is not exactly 32 bytes or if AES
// initialization fails.
func NewEncryptor(cfg EncryptionConfig) (*Encryptor, error) {
if !cfg.Enabled {
return &Encryptor{
aead: nil,
keyVersion: cfg.KeyVersion,
enabled: false,
}, nil
}
keyBytes, err := base64.StdEncoding.DecodeString(cfg.AESKey)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 AES key: %w", err)
}
if len(keyBytes) != 32 {
return nil, fmt.Errorf("AES key must be 32 bytes for AES-256, got %d bytes", len(keyBytes))
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM mode: %w", err)
}
return &Encryptor{
aead: aead,
keyVersion: cfg.KeyVersion,
enabled: true,
}, nil
}
// Encrypt encrypts plaintext using AES-256-GCM and returns a base64-encoded
// string prefixed with the key version for rotation support.
//
// Format: "enc:v{version}:{base64(nonce+ciphertext+tag)}"
// The nonce (12 bytes for GCM) is prepended to the ciphertext for self-contained
// decryption without needing to store the nonce separately.
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
if !e.enabled {
// Encryption disabled — return plaintext with a marker so we can
// still detect unencrypted values during migration/rotation.
return plaintext, nil
}
if plaintext == "" {
return "", nil
}
nonce := make([]byte, e.aead.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
applogger.L().Errorf("failed to generate nonce for encryption: %v", err)
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// AEAD Seal appends the authentication tag to the ciphertext.
ciphertext := e.aead.Seal(nonce, nonce, []byte(plaintext), nil)
encoded := base64.StdEncoding.EncodeToString(ciphertext)
return fmt.Sprintf("enc:v%d:%s", e.keyVersion, encoded), nil
}
// Decrypt decrypts an AES-256-GCM encrypted string. Supports the versioned
// format "enc:v{version}:{base64}" as well as plain base64 (for migration).
// Returns the original plaintext.
func (e *Encryptor) Decrypt(ciphertext string) (string, error) {
if !e.enabled {
// Encryption disabled — values stored as plaintext
return ciphertext, nil
}
if ciphertext == "" {
return "", nil
}
// Strip version prefix if present
payload := ciphertext
if isEncryptedPrefix(ciphertext) {
payload = stripEncryptedPrefix(ciphertext)
}
data, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return "", fmt.Errorf("failed to decode base64 ciphertext: %w", err)
}
nonceSize := e.aead.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short: missing nonce")
}
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
plaintext, err := e.aead.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
applogger.L().Errorf("failed to decrypt data (GCM authentication failed): %v", err)
return "", fmt.Errorf("decryption failed: ciphertext may be corrupted or tampered with: %w", err)
}
return string(plaintext), nil
}
// EncryptField encrypts a sensitive field value based on its type.
// This is a convenience wrapper that logs the field type for audit purposes.
func (e *Encryptor) EncryptField(value string, fieldType SensitiveFieldType) (string, error) {
result, err := e.Encrypt(value)
if err != nil {
applogger.L().Errorf("failed to encrypt field %s: %v", fieldType, err)
return "", fmt.Errorf("failed to encrypt %s: %w", fieldType, err)
}
return result, nil
}
// DecryptField decrypts a sensitive field value based on its type.
// This is a convenience wrapper that logs the field type for audit purposes.
func (e *Encryptor) DecryptField(value string, fieldType SensitiveFieldType) (string, error) {
result, err := e.Decrypt(value)
if err != nil {
applogger.L().Errorf("failed to decrypt field %s: %v", fieldType, err)
return "", fmt.Errorf("failed to decrypt %s: %w", fieldType, err)
}
return result, nil
}
// IsEnabled returns whether encryption is currently active.
func (e *Encryptor) IsEnabled() bool {
return e.enabled
}
// KeyVersion returns the current encryption key version.
func (e *Encryptor) KeyVersion() int {
return e.keyVersion
}
// IsEncrypted checks whether a string value has the encrypted prefix,
// indicating it was encrypted by this Encryptor.
func IsEncrypted(value string) bool {
return isEncryptedPrefix(value)
}
// isEncryptedPrefix checks for the "enc:v" prefix that marks encrypted values.
func isEncryptedPrefix(value string) bool {
return len(value) > 6 && value[:5] == "enc:v"
}
// stripEncryptedPrefix removes the "enc:v{version}:" prefix from an encrypted
// value, returning just the base64 payload.
func stripEncryptedPrefix(value string) string {
// Find the colon after "enc:v{version}"
for i := 5; i < len(value); i++ {
if value[i] == ':' {
return value[i+1:]
}
}
// No colon found — malformed prefix, return as-is
return value
}
// GenerateAESKey generates a new random 32-byte AES-256 key and returns it
// as a base64-encoded string suitable for use in EncryptionConfig.
// This should be called once during initial setup and the key stored securely
// (e.g., in a secrets manager, not in the config file).
func GenerateAESKey() (string, error) {
key := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return "", fmt.Errorf("failed to generate AES key: %w", err)
}
return base64.StdEncoding.EncodeToString(key), nil
}