From 38f16ba677bd5328ab9baaab562f8f536c94e760 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 13 Oct 2025 18:05:12 +0530 Subject: [PATCH] feat: Secure external credentials with database encryption (#12648) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changelog - Added conditional Active Record encryption to every external credential we store (SMTP/IMAP passwords, Twilio tokens, Slack/OpenAI hook tokens, Facebook/Instagram tokens, LINE/Telegram keys, Twitter secrets) so new writes are encrypted whenever Chatwoot.encryption_configured? is true; legacy installs still receive plaintext until their secrets are updated. - Tuned encryption settings in config/application.rb to allow legacy reads (support_unencrypted_data) and to extend deterministic queries so lookups continue to match plaintext rows during the rollout; added TODOs to retire the fallback once encryption becomes mandatory. - Introduced an MFA-pipeline test suite (spec/models/external_credentials_encryption_spec.rb) plus shared examples to verify each attribute encrypts at rest and that plaintext records re-encrypt on update, with a dedicated Telegram case. The existing MFA GitHub workflow now runs these tests using the preconfigured encryption keys. fixes: https://linear.app/chatwoot/issue/CW-5453/encrypt-sensitive-credentials-stored-in-plain-text-in-database ## Testing Instructions 1. Instance without encryption keys - Unset ACTIVE_RECORD_ENCRYPTION_* vars (or run in an environment where they’re absent). - Create at least one credentialed channel (e.g., Email SMTP). - Confirm workflows still function (send/receive mail or a similar sanity check). - In the DB you should still see plaintext values—this confirms the guard prevents encryption when keys are missing. 2. Instance with encryption keys - Configure the three encryption env vars and restart. - Pick a couple of representative integrations (e.g., Email SMTP + Twilio SMS). - Legacy channel check: - Use existing records created before enabling keys. Trigger their workflow (send an email / SMS, or hit the webhook) to ensure they still authenticate. - Inspect the raw column—value remains plaintext until changed. - Update legacy channel: - Edit one legacy channel’s credential (e.g., change SMTP password). - Verify the operation still works and the stored value is now encrypted (raw column differs, accessor returns original). - New channel creation: - Create a new channel of the same type; confirm functionality and that the stored credential is encrypted from the start. --------- Co-authored-by: Muhsin Keloth --- .github/workflows/run_mfa_spec.yml | 1 + app/models/channel/email.rb | 6 + app/models/channel/facebook_page.rb | 6 + app/models/channel/instagram.rb | 3 + app/models/channel/line.rb | 6 + app/models/channel/telegram.rb | 3 + app/models/channel/twilio_sms.rb | 3 + app/models/channel/twitter_profile.rb | 6 + app/models/integrations/hook.rb | 3 + config/application.rb | 6 + ...rd_external_credentials_encryption_spec.rb | 113 ++++++++++++++++++ .../encrypted_external_credential_examples.rb | 21 ++++ 12 files changed, 177 insertions(+) create mode 100644 spec/models/application_record_external_credentials_encryption_spec.rb create mode 100644 spec/support/examples/encrypted_external_credential_examples.rb diff --git a/.github/workflows/run_mfa_spec.yml b/.github/workflows/run_mfa_spec.yml index 61b406f8a..69d019cc9 100644 --- a/.github/workflows/run_mfa_spec.yml +++ b/.github/workflows/run_mfa_spec.yml @@ -70,6 +70,7 @@ jobs: spec/services/mfa/authentication_service_spec.rb \ spec/requests/api/v1/profile/mfa_controller_spec.rb \ spec/controllers/devise_overrides/sessions_controller_spec.rb \ + spec/models/application_record_external_credentials_encryption_spec.rb \ --profile=10 \ --format documentation env: diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index a8fadb61e..b1124dd75 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -40,6 +40,12 @@ class Channel::Email < ApplicationRecord AUTHORIZATION_ERROR_THRESHOLD = 10 + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :imap_password + encrypts :smtp_password + end + self.table_name = 'channel_email' EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto, diff --git a/app/models/channel/facebook_page.rb b/app/models/channel/facebook_page.rb index 1b6e151cb..1866d245b 100644 --- a/app/models/channel/facebook_page.rb +++ b/app/models/channel/facebook_page.rb @@ -21,6 +21,12 @@ class Channel::FacebookPage < ApplicationRecord include Channelable include Reauthorizable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :page_access_token + encrypts :user_access_token + end + self.table_name = 'channel_facebook_pages' validates :page_id, uniqueness: { scope: :account_id } diff --git a/app/models/channel/instagram.rb b/app/models/channel/instagram.rb index 964a4c1a2..7e6444b30 100644 --- a/app/models/channel/instagram.rb +++ b/app/models/channel/instagram.rb @@ -19,6 +19,9 @@ class Channel::Instagram < ApplicationRecord include Reauthorizable self.table_name = 'channel_instagram' + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :access_token if Chatwoot.encryption_configured? + AUTHORIZATION_ERROR_THRESHOLD = 1 validates :access_token, presence: true diff --git a/app/models/channel/line.rb b/app/models/channel/line.rb index a417dbf64..63b0924da 100644 --- a/app/models/channel/line.rb +++ b/app/models/channel/line.rb @@ -18,6 +18,12 @@ class Channel::Line < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :line_channel_secret + encrypts :line_channel_token + end + self.table_name = 'channel_line' EDITABLE_ATTRS = [:line_channel_id, :line_channel_secret, :line_channel_token].freeze diff --git a/app/models/channel/telegram.rb b/app/models/channel/telegram.rb index b00897614..b18c6dbc9 100644 --- a/app/models/channel/telegram.rb +++ b/app/models/channel/telegram.rb @@ -17,6 +17,9 @@ class Channel::Telegram < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :bot_token, deterministic: true if Chatwoot.encryption_configured? + self.table_name = 'channel_telegram' EDITABLE_ATTRS = [:bot_token].freeze diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index 73e5c873e..2f9130cbb 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -28,6 +28,9 @@ class Channel::TwilioSms < ApplicationRecord self.table_name = 'channel_twilio_sms' + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :auth_token if Chatwoot.encryption_configured? + validates :account_sid, presence: true # The same parameter is used to store api_key_secret if api_key authentication is opted validates :auth_token, presence: true diff --git a/app/models/channel/twitter_profile.rb b/app/models/channel/twitter_profile.rb index d0f765e9f..4ec167ce5 100644 --- a/app/models/channel/twitter_profile.rb +++ b/app/models/channel/twitter_profile.rb @@ -19,6 +19,12 @@ class Channel::TwitterProfile < ApplicationRecord include Channelable + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + if Chatwoot.encryption_configured? + encrypts :twitter_access_token + encrypts :twitter_access_token_secret + end + self.table_name = 'channel_twitter_profiles' validates :profile_id, uniqueness: { scope: :account_id } diff --git a/app/models/integrations/hook.rb b/app/models/integrations/hook.rb index ca77fa13d..97d3f91ae 100644 --- a/app/models/integrations/hook.rb +++ b/app/models/integrations/hook.rb @@ -21,6 +21,9 @@ class Integrations::Hook < ApplicationRecord before_validation :ensure_hook_type after_create :trigger_setup_if_crm + # TODO: Remove guard once encryption keys become mandatory (target 3-4 releases out). + encrypts :access_token, deterministic: true if Chatwoot.encryption_configured? + validates :account_id, presence: true validates :app_id, presence: true validates :inbox_id, presence: true, if: -> { hook_type == 'inbox' } diff --git a/config/application.rb b/config/application.rb index d644dd28f..aa150794a 100644 --- a/config/application.rb +++ b/config/application.rb @@ -75,7 +75,11 @@ module Chatwoot config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'] config.active_record.encryption.deterministic_key = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY', nil) config.active_record.encryption.key_derivation_salt = ENV.fetch('ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT', nil) + # TODO: Remove once encryption is mandatory and legacy plaintext is migrated. config.active_record.encryption.support_unencrypted_data = true + # Extend deterministic queries so they match both encrypted and plaintext rows + config.active_record.encryption.extend_queries = true + # Store a per-row key reference to support future key rotation config.active_record.encryption.store_key_references = true end end @@ -94,6 +98,8 @@ module Chatwoot end def self.encryption_configured? + # TODO: Once Active Record encryption keys are mandatory (target 3-4 releases out), + # remove this guard and assume encryption is always enabled. # Check if proper encryption keys are configured # MFA/2FA features should only be enabled when proper keys are set ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY'].present? && diff --git a/spec/models/application_record_external_credentials_encryption_spec.rb b/spec/models/application_record_external_credentials_encryption_spec.rb new file mode 100644 index 000000000..65c347434 --- /dev/null +++ b/spec/models/application_record_external_credentials_encryption_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ApplicationRecord do + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :smtp_password, + value: 'smtp-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_email, + attribute: :imap_password, + value: 'imap-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twilio_sms, + attribute: :auth_token, + value: 'twilio-secret' + + it_behaves_like 'encrypted external credential', + factory: :integrations_hook, + attribute: :access_token, + value: 'hook-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :page_access_token, + value: 'fb-page-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_facebook_page, + attribute: :user_access_token, + value: 'fb-user-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_instagram, + attribute: :access_token, + value: 'ig-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_secret, + value: 'line-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_line, + attribute: :line_channel_token, + value: 'line-token-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_telegram, + attribute: :bot_token, + value: 'telegram-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token, + value: 'twitter-access-secret' + + it_behaves_like 'encrypted external credential', + factory: :channel_twitter_profile, + attribute: :twitter_access_token_secret, + value: 'twitter-secret-secret' + + context 'when backfilling legacy plaintext' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'reads existing plaintext and encrypts on update' do + account = create(:account) + channel = create(:channel_email, account: account, smtp_password: nil) + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + legacy_record = Channel::Email.find(channel.id) + expect(legacy_record.smtp_password).to eq('legacy-plain') + + legacy_record.update!(smtp_password: 'encrypted-now') + + stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password) + expect(stored_value).to be_present + expect(stored_value).not_to include('encrypted-now') + expect(legacy_record.smtp_password).to eq('encrypted-now') + end + end + + context 'when looking up telegram legacy records' do + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + end + + it 'finds plaintext records via fallback lookup' do + channel = create(:channel_telegram, bot_token: 'legacy-token') + + # Simulate legacy plaintext by updating the DB directly + sql = ActiveRecord::Base.send( + :sanitize_sql_array, + ['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id] + ) + ActiveRecord::Base.connection.execute(sql) + + found = Channel::Telegram.find_by(bot_token: 'legacy-token') + expect(found).to eq(channel) + end + end +end diff --git a/spec/support/examples/encrypted_external_credential_examples.rb b/spec/support/examples/encrypted_external_credential_examples.rb new file mode 100644 index 000000000..c67d814a9 --- /dev/null +++ b/spec/support/examples/encrypted_external_credential_examples.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'encrypted external credential' do |factory:, attribute:, value: 'secret-token'| + before do + skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured? + if defined?(Facebook::Messenger::Subscriptions) + allow(Facebook::Messenger::Subscriptions).to receive(:subscribe).and_return(true) + allow(Facebook::Messenger::Subscriptions).to receive(:unsubscribe).and_return(true) + end + end + + it "encrypts #{attribute} at rest" do + record = create(factory, attribute => value) + + raw_stored_value = record.reload.read_attribute_before_type_cast(attribute).to_s + expect(raw_stored_value).to be_present + expect(raw_stored_value).not_to include(value) + expect(record.public_send(attribute)).to eq(value) + expect(record.encrypted_attribute?(attribute)).to be(true) + end +end