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" "strconv" "strings" 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 aeads map[int]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) { return NewEncryptorWithPreviousKeys(cfg, nil) } // NewEncryptorWithPreviousKeys keeps old keys decrypt-only while new writes use cfg.KeyVersion. func NewEncryptorWithPreviousKeys(cfg EncryptionConfig, previousKeys map[int]string) (*Encryptor, error) { if cfg.KeyVersion == 0 { cfg.KeyVersion = 1 } if !cfg.Enabled { return &Encryptor{ aead: nil, keyVersion: cfg.KeyVersion, enabled: false, }, nil } aead, err := newAEAD(cfg.AESKey) if err != nil { return nil, err } aeads := map[int]cipher.AEAD{cfg.KeyVersion: aead} for version, key := range previousKeys { if version < 1 || version == cfg.KeyVersion { return nil, fmt.Errorf("invalid previous encryption key version v%d", version) } oldAEAD, err := newAEAD(key) if err != nil { return nil, fmt.Errorf("invalid previous encryption key v%d: %w", version, err) } aeads[version] = oldAEAD } return &Encryptor{ aead: aead, aeads: aeads, keyVersion: cfg.KeyVersion, enabled: true, }, nil } func newAEAD(encodedKey string) (cipher.AEAD, error) { keyBytes, err := base64.StdEncoding.DecodeString(encodedKey) 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) } return cipher.NewGCM(block) } // 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 } aead := e.aead payload := ciphertext if isEncryptedPrefix(ciphertext) { parts := strings.SplitN(ciphertext, ":", 3) version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v")) if err != nil || len(parts) != 3 { return "", errors.New("invalid encrypted value prefix") } var ok bool aead, ok = e.aeads[version] if !ok { return "", fmt.Errorf("encryption key v%d is not configured", version) } payload = stripEncryptedPrefix(ciphertext) } data, err := base64.StdEncoding.DecodeString(payload) if err != nil { return "", fmt.Errorf("failed to decode base64 ciphertext: %w", err) } nonceSize := aead.NonceSize() if len(data) < nonceSize { return "", errors.New("ciphertext too short: missing nonce") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] plaintext, err := 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 }