From 5ac4284b4731ab8cfe28e377e3101f77e1a20862 Mon Sep 17 00:00:00 2001 From: Ubayed Bin Sufian Date: Mon, 17 Nov 2025 18:55:40 +0600 Subject: [PATCH] feat: encrypt SMTP passwords with AES-256-CBC Add encryption utility class and integrate with SMTP settings to protect sensitive credentials. Backwards compatible with existing plain-text passwords. Automatic encryption on first save. - Add Wpfa_Mailconnect_Encryption utility class - Encrypt smtp_pass field on save - Decrypt before passing to PHPMailer - Mask passwords in admin UI with ******** - Use WordPress salts for site-specific keys --- .../class-wpfa-mailconnect-encryption.php | 185 ++++++++++++++++++ includes/class-wpfa-mailconnect-smtp.php | 117 ++++++----- includes/class-wpfa-mailconnect.php | 5 + 3 files changed, 258 insertions(+), 49 deletions(-) create mode 100644 includes/class-wpfa-mailconnect-encryption.php diff --git a/includes/class-wpfa-mailconnect-encryption.php b/includes/class-wpfa-mailconnect-encryption.php new file mode 100644 index 0000000..c9bcba4 --- /dev/null +++ b/includes/class-wpfa-mailconnect-encryption.php @@ -0,0 +1,185 @@ + + */ +class Wpfa_Mailconnect_Encryption { + + /** + * Encryption method to use. + * + * @since 1.2.2 + */ + const CIPHER_METHOD = 'AES-256-CBC'; + + /** + * Prefix to identify encrypted values. + * + * @since 1.2.2 + */ + const ENCRYPTED_PREFIX = 'wpfa_enc_'; + + /** + * Encrypts a string value. + * + * Uses OpenSSL with AES-256-CBC encryption and WordPress salts for the key. + * + * @since 1.2.2 + * @param string $value The plain text value to encrypt. + * @return string The encrypted value with prefix, or original value if encryption fails. + */ + public static function encrypt( $value ) { + // Return empty if value is empty + if ( empty( $value ) ) { + return $value; + } + + // Don't re-encrypt already encrypted values + if ( self::is_encrypted( $value ) ) { + return $value; + } + + // Check if OpenSSL is available + if ( ! function_exists( 'openssl_encrypt' ) ) { + error_log( 'WPFA MailConnect: OpenSSL not available, storing value without encryption.' ); + return $value; + } + + try { + $key = self::get_encryption_key(); + $iv = openssl_random_pseudo_bytes( openssl_cipher_iv_length( self::CIPHER_METHOD ) ); + + $encrypted = openssl_encrypt( + $value, + self::CIPHER_METHOD, + $key, + 0, + $iv + ); + + if ( false === $encrypted ) { + error_log( 'WPFA MailConnect: Encryption failed.' ); + return $value; + } + + // Combine IV and encrypted data, then base64 encode + $result = base64_encode( $iv . $encrypted ); + + // Add prefix to identify encrypted values + return self::ENCRYPTED_PREFIX . $result; + + } catch ( Exception $e ) { + error_log( 'WPFA MailConnect Encryption Error: ' . $e->getMessage() ); + return $value; + } + } + + /** + * Decrypts an encrypted string value. + * + * @since 1.2.2 + * @param string $value The encrypted value (with prefix). + * @return string The decrypted plain text value, or original if not encrypted. + */ + public static function decrypt( $value ) { + // Return empty if value is empty + if ( empty( $value ) ) { + return $value; + } + + // If not encrypted, return as-is (backwards compatibility) + if ( ! self::is_encrypted( $value ) ) { + return $value; + } + + // Check if OpenSSL is available + if ( ! function_exists( 'openssl_decrypt' ) ) { + error_log( 'WPFA MailConnect: OpenSSL not available for decryption.' ); + return ''; + } + + try { + // Remove prefix + $value = substr( $value, strlen( self::ENCRYPTED_PREFIX ) ); + + // Decode from base64 + $decoded = base64_decode( $value, true ); + + if ( false === $decoded ) { + error_log( 'WPFA MailConnect: Base64 decode failed.' ); + return ''; + } + + $key = self::get_encryption_key(); + $iv_length = openssl_cipher_iv_length( self::CIPHER_METHOD ); + + // Extract IV and encrypted data + $iv = substr( $decoded, 0, $iv_length ); + $encrypted = substr( $decoded, $iv_length ); + + $decrypted = openssl_decrypt( + $encrypted, + self::CIPHER_METHOD, + $key, + 0, + $iv + ); + + if ( false === $decrypted ) { + error_log( 'WPFA MailConnect: Decryption failed.' ); + return ''; + } + + return $decrypted; + + } catch ( Exception $e ) { + error_log( 'WPFA MailConnect Decryption Error: ' . $e->getMessage() ); + return ''; + } + } + + /** + * Checks if a value is encrypted. + * + * @since 1.2.2 + * @param string $value The value to check. + * @return bool True if encrypted, false otherwise. + */ + public static function is_encrypted( $value ) { + return is_string( $value ) && strpos( $value, self::ENCRYPTED_PREFIX ) === 0; + } + + /** + * Generates an encryption key based on WordPress salts. + * + * Uses WordPress AUTH_KEY and SECURE_AUTH_KEY salts to create a unique, + * site-specific encryption key. + * + * @since 1.2.2 + * @return string The encryption key. + */ + private static function get_encryption_key() { + // Use WordPress salts to create a unique key per site + $key = AUTH_KEY . SECURE_AUTH_KEY; + + // Hash to ensure consistent key length for AES-256 (32 bytes) + return hash( 'sha256', $key, true ); + } +} \ No newline at end of file diff --git a/includes/class-wpfa-mailconnect-smtp.php b/includes/class-wpfa-mailconnect-smtp.php index a8e4a59..86ab478 100644 --- a/includes/class-wpfa-mailconnect-smtp.php +++ b/includes/class-wpfa-mailconnect-smtp.php @@ -205,14 +205,26 @@ public function sanitize_smtp_options( $input ) { $output[ $id ] = $input[ $id ] === '1' ? '1' : '0'; } } else { - // Handle sanitization for other field types (optional but recommended) + // Handle sanitization for other field types if ( isset( $input[ $id ] ) ) { switch ( $args['type'] ) { case 'text': - case 'password': case 'select': $output[ $id ] = sanitize_text_field( $input[ $id ] ); break; + case 'password': + // ENCRYPTION: Only encrypt if password was actually changed + // Check if the submitted value is not empty and different from placeholder + if ( ! empty( $input[ $id ] ) && $input[ $id ] !== '********' ) { + // Encrypt the new password before saving + $output[ $id ] = Wpfa_Mailconnect_Encryption::encrypt( $input[ $id ] ); + } elseif ( empty( $input[ $id ] ) ) { + // If empty, keep the existing encrypted value + $existing_options = get_option( 'smtp_options', array() ); + $output[ $id ] = isset( $existing_options[ $id ] ) ? $existing_options[ $id ] : ''; + } + // If it's '********', it means user didn't change it, keep existing value + break; case 'number': $output[ $id ] = absint( $input[ $id ] ); break; @@ -243,53 +255,55 @@ public function test_section_callback() { * @return void */ public function render_field( $args ) { - $options = get_option( 'smtp_options', array() ); - $id = sanitize_key( $args['id'] ); + $options = get_option( 'smtp_options', array() ); + $id = sanitize_key( $args['id'] ); // Check type first, then set value once - if ( isset( $args['type'] ) && 'password' === $args['type'] ) { - // Password fields: use saved value or empty string (never show default) - $value = isset( $options[ $id ] ) ? $options[ $id ] : ''; + if ( isset( $args['type'] ) && 'password' === $args['type'] ) { + // Password fields: show placeholder if value exists, empty string if not + // NEVER show the actual encrypted value or decrypted password + $value = isset( $options[ $id ] ) && ! empty( $options[ $id ] ) ? '********' : ''; } else { // All other fields: use saved value or default $value = isset( $options[ $id ] ) ? $options[ $id ] : $args['default']; - } - - if ( isset( $args['type'] ) && 'select' === $args['type'] ) { - echo ''; - } elseif ( isset( $args['type'] ) && 'checkbox' === $args['type'] ) { - // Checkbox handling: value is 1 if checked, 0 if not set/unchecked - $checked = ( '1' === $value || true === $value ); - printf( - '', - esc_attr( $id ), - esc_attr( $id ), - checked( $checked, true, false ) - ); - } else { - // text/password/number - printf( - '', - esc_attr( $args['type'] ), - esc_attr( $id ), - esc_attr( $id ), - esc_attr( $value ) - ); - } + } - if ( isset( $args['description'] ) ) { - printf( '

%s

', esc_html( $args['description'] ) ); - } - } + if ( isset( $args['type'] ) && 'select' === $args['type'] ) { + echo ''; + } elseif ( isset( $args['type'] ) && 'checkbox' === $args['type'] ) { + // Checkbox handling: value is 1 if checked, 0 if not set/unchecked + $checked = ( '1' === $value || true === $value ); + printf( + '', + esc_attr( $id ), + esc_attr( $id ), + checked( $checked, true, false ) + ); + } else { + // text/password/number + printf( + '', + esc_attr( $args['type'] ), + esc_attr( $id ), + esc_attr( $id ), + esc_attr( $value ), + ( 'password' === $args['type'] && ! empty( $value ) ) ? 'Leave blank to keep current password' : '' + ); + } + + if ( isset( $args['description'] ) ) { + printf( '

%s

', esc_html( $args['description'] ) ); + } + } /** * Renders the plugin settings page. @@ -518,17 +532,22 @@ public function handle_test_email() { * @param PHPMailer $phpmailer The PHPMailer instance to configure. * @return void */ - public function phpmailer_override( $phpmailer ) { - $options = get_option( 'smtp_options', array() ); + public function phpmailer_override( $phpmailer ) { + $options = get_option( 'smtp_options', array() ); $user = isset( $options['smtp_user'] ) ? trim( $options['smtp_user'] ) : ''; - $pass = isset( $options['smtp_pass'] ) ? $options['smtp_pass'] : ''; + + // DECRYPTION: Decrypt password before use + $pass = isset( $options['smtp_pass'] ) ? Wpfa_Mailconnect_Encryption::decrypt( $options['smtp_pass'] ) : ''; + $host = isset( $options['smtp_host'] ) ? trim( $options['smtp_host'] ) : 'localhost'; $port = isset( $options['smtp_port'] ) ? (int) $options['smtp_port'] : 25; + // Validate port range (1-65535) if ( $port < 1 || $port > 65535 ) { $port = 25; // fallback to default SMTP port } + $secure = isset( $options['smtp_secure'] ) ? $options['smtp_secure'] : ''; $auth = isset( $options['smtp_auth'] ) ? (bool) $options['smtp_auth'] : false; $from = isset( $options['smtp_from'] ) ? trim( $options['smtp_from'] ) : get_option( 'admin_email' ); @@ -541,7 +560,7 @@ public function phpmailer_override( $phpmailer ) { $phpmailer->SMTPAuth = $auth; $phpmailer->Port = $port; $phpmailer->Username = $user; - $phpmailer->Password = $pass; + $phpmailer->Password = $pass; // Decrypted password $phpmailer->SMTPSecure = $secure; // Validate 'From' email address before assignment @@ -555,7 +574,7 @@ public function phpmailer_override( $phpmailer ) { $phpmailer->FromName = $name; } - // Disable debug output - $phpmailer->SMTPDebug = 0; + // Disable debug output + $phpmailer->SMTPDebug = 0; } } \ No newline at end of file diff --git a/includes/class-wpfa-mailconnect.php b/includes/class-wpfa-mailconnect.php index 8b2778b..16db82e 100644 --- a/includes/class-wpfa-mailconnect.php +++ b/includes/class-wpfa-mailconnect.php @@ -171,6 +171,11 @@ private function load_dependencies() { */ require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-wpfa-mailconnect-smtp.php'; + /** + * The class responsible for encryption utilities. + */ + require_once plugin_dir_path( dirname( __FILE__ ) ) . 'includes/class-wpfa-mailconnect-encryption.php'; + $this->loader = new Wpfa_Mailconnect_Loader(); }