diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 3749dda9fc..8d47f99b22 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,12 @@ +* Allow MessageEncryptor to take advantage of authenticated encryption modes. + + AEAD modes like `aes-256-gcm` provide both confidentiality and data + authenticity, eliminating the need to use MessageVerifier to check if the + encrypted data has been tampered with. This speeds up encryption/decryption + and results in shorter cipher text. + + *Bart de Water* + * Introduce `assert_changes` and `assert_no_changes`. `assert_changes` is a more general `assert_difference` that works with any diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index 721efea789..87efe117c5 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -1,6 +1,7 @@ require 'openssl' require 'base64' require 'active_support/core_ext/array/extract_options' +require 'active_support/message_verifier' module ActiveSupport # MessageEncryptor is a simple way to encrypt values which get stored @@ -28,6 +29,16 @@ module ActiveSupport end end + module NullVerifier #:nodoc: + def self.verify(value) + value + end + + def self.generate(value) + value + end + end + class InvalidMessage < StandardError; end OpenSSLCipherError = OpenSSL::Cipher::CipherError @@ -40,7 +51,8 @@ module ActiveSupport # Options: # * :cipher - Cipher to use. Can be any cipher returned by # OpenSSL::Cipher.ciphers. Default is 'aes-256-cbc'. - # * :digest - String of digest to use for signing. Default is +SHA1+. + # * :digest - String of digest to use for signing. Default is + # +SHA1+. Ignored when using an AEAD cipher like 'aes-256-gcm'. # * :serializer - Object serializer to use. Default is +Marshal+. def initialize(secret, *signature_key_or_options) options = signature_key_or_options.extract_options! @@ -48,7 +60,8 @@ module ActiveSupport @secret = secret @sign_secret = sign_secret @cipher = options[:cipher] || 'aes-256-cbc' - @verifier = MessageVerifier.new(@sign_secret || @secret, digest: options[:digest] || 'SHA1', serializer: NullSerializer) + @digest = options[:digest] || 'SHA1' unless aead_mode? + @verifier = resolve_verifier @serializer = options[:serializer] || Marshal end @@ -73,20 +86,28 @@ module ActiveSupport # Rely on OpenSSL for the initialization vector iv = cipher.random_iv + cipher.auth_data = "" if aead_mode? encrypted_data = cipher.update(@serializer.dump(value)) encrypted_data << cipher.final - "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}" + blob = "#{::Base64.strict_encode64 encrypted_data}--#{::Base64.strict_encode64 iv}" + blob << "--#{::Base64.strict_encode64 cipher.auth_tag}" if aead_mode? + blob end def _decrypt(encrypted_message) cipher = new_cipher - encrypted_data, iv = encrypted_message.split("--".freeze).map {|v| ::Base64.strict_decode64(v)} + encrypted_data, iv, auth_tag = encrypted_message.split("--".freeze).map {|v| ::Base64.strict_decode64(v)} + raise InvalidMessage if aead_mode? && auth_tag.bytes.length != 16 cipher.decrypt cipher.key = @secret cipher.iv = iv + if aead_mode? + cipher.auth_tag = auth_tag + cipher.auth_data = "" + end decrypted_data = cipher.update(encrypted_data) decrypted_data << cipher.final @@ -103,5 +124,17 @@ module ActiveSupport def verifier @verifier end + + def aead_mode? + @aead_mode ||= new_cipher.authenticated? + end + + def resolve_verifier + if aead_mode? + NullVerifier + else + MessageVerifier.new(@sign_secret || @secret, digest: @digest, serializer: NullSerializer) + end + end end end diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index a1ff4c1d3e..5dfa187f36 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -70,6 +70,24 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_not_verified([iv, message] * bad_encoding_characters) end + def test_aead_mode_encryption + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: 'aes-256-gcm') + message = encryptor.encrypt_and_sign(@data) + assert_equal @data, encryptor.decrypt_and_verify(message) + end + + def test_messing_with_aead_values_causes_failures + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: 'aes-256-gcm') + text, iv, auth_tag = encryptor.encrypt_and_sign(@data).split("--") + assert_not_decrypted([iv, text, auth_tag] * "--") + assert_not_decrypted([munge(text), iv, auth_tag] * "--") + assert_not_decrypted([text, munge(iv), auth_tag] * "--") + assert_not_decrypted([text, iv, munge(auth_tag)] * "--") + assert_not_decrypted([munge(text), munge(iv), munge(auth_tag)] * "--") + assert_not_decrypted([text, iv] * "--") + assert_not_decrypted([text, iv, auth_tag[0..-2]] * "--") + end + private def assert_not_decrypted(value)