253 lines
8.2 KiB
Go
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
|
|
} |