diff --git a/CHANGELOG.md b/CHANGELOG.md index 93bf48f..dd2b03f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.3.0 (unreleased) + +- Added support for key rotation + ## 2.2.0 (2023-07-02) - Removed support for Ruby < 3 and Rails < 6.1 diff --git a/app/controllers/ahoy/messages_controller.rb b/app/controllers/ahoy/messages_controller.rb index 24bbb3a..44a583d 100644 --- a/app/controllers/ahoy/messages_controller.rb +++ b/app/controllers/ahoy/messages_controller.rb @@ -11,24 +11,23 @@ def open end def click - if params[:id] - # legacy + legacy = params[:id] + if legacy token = params[:id].to_s + campaign = nil url = params[:url].to_s signature = params[:signature].to_s - expected_signature = OpenSSL::HMAC.hexdigest("SHA1", AhoyEmail::Utils.secret_token, url) else token = params[:t].to_s campaign = params[:c].to_s url = params[:u].to_s signature = params[:s].to_s - expected_signature = AhoyEmail::Utils.signature(token: token, campaign: campaign, url: url) end redirect_options = {} redirect_options[:allow_other_host] = true if ActionPack::VERSION::MAJOR >= 7 - if ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature) + if AhoyEmail::Utils.signature_verified?(legacy: legacy, token: token, campaign: campaign, url: url, signature: signature) data = {} data[:campaign] = campaign if campaign data[:token] = token diff --git a/lib/ahoy_email/utils.rb b/lib/ahoy_email/utils.rb index 06ce4f8..825fca6 100644 --- a/lib/ahoy_email/utils.rb +++ b/lib/ahoy_email/utils.rb @@ -7,13 +7,28 @@ class Utils } class << self - def signature(token:, campaign:, url:) + def signature(token:, campaign:, url:, secret_token: nil) + secret_token ||= self.secret_tokens.first + # encode and join with a character outside encoding data = [token, campaign, url].map { |v| Base64.strict_encode64(v.to_s) }.join("|") Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", secret_token, data), padding: false) end + def signature_verified?(legacy:, token:, campaign:, url:, signature:) + secret_tokens.any? do |secret_token| + expected_signature = + if legacy + OpenSSL::HMAC.hexdigest("SHA1", secret_token, url) + else + signature(token: token, campaign: campaign, url: url, secret_token: secret_token) + end + + ActiveSupport::SecurityUtils.secure_compare(signature, expected_signature) + end + end + def publish(name, event) method_name = "track_#{name}" AhoyEmail.subscribers.each do |subscriber| @@ -27,8 +42,8 @@ def publish(name, event) end end - def secret_token - AhoyEmail.secret_token || (raise "Secret token is empty") + def secret_tokens + Array(AhoyEmail.secret_token || (raise "Secret token is empty")) end end end