diff options
-rw-r--r-- | activesupport/CHANGELOG.md | 11 | ||||
-rw-r--r-- | activesupport/lib/active_support/message_encryptor.rb | 32 | ||||
-rw-r--r-- | activesupport/lib/active_support/message_verifier.rb | 36 | ||||
-rw-r--r-- | activesupport/lib/active_support/messages/rotation_configuration.rb | 25 | ||||
-rw-r--r-- | activesupport/lib/active_support/messages/rotator.rb | 81 | ||||
-rw-r--r-- | activesupport/test/message_encryptor_test.rb | 118 | ||||
-rw-r--r-- | activesupport/test/message_verifier_test.rb | 90 | ||||
-rw-r--r-- | activesupport/test/messages/rotation_configuration_test.rb | 43 | ||||
-rw-r--r-- | railties/lib/rails/application.rb | 5 | ||||
-rw-r--r-- | railties/test/application/middleware/cookies_test.rb | 143 |
10 files changed, 579 insertions, 5 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 56013c5f95..487984cbd3 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,14 @@ +* Add key rotation support to `MessageEncryptor` and `MessageVerifier` + + This change introduces a `rotate` method to both the `MessageEncryptor` and + `MessageVerifier` classes. This method accepts the same arguments and + options as the given classes' constructor. The `encrypt_and_verify` method + for `MessageEncryptor` and the `verified` method for `MessageVerifier` also + accept an optional keyword argument `:on_rotation` block which is called + when a rotated instance is used to decrypt or verify the message. + + *Michael J Coyne* + * Deprecate `Module#reachable?` method. *bogdanvlviv* diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index 27620f56be..003fb4c354 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -54,7 +54,37 @@ module ActiveSupport # # Then the messages can be verified and returned upto the expire time. # Thereafter, verifying returns +nil+. + # + # === Rotating keys + # + # This class also defines a +rotate+ method which can be used to rotate out + # encryption keys no longer in use. + # + # This method is called with an options hash where a +:cipher+ option and + # either a +:raw_key+ or +:secret+ option must be defined. If +:raw_key+ is + # defined, it is used directly for the underlying encryption function. If + # the +:secret+ option is defined, a +:salt+ option must also be defined and + # a +KeyGenerator+ instance will be used to derive a key using +:salt+. When + # +:secret+ is used, a +:key_generator+ option may also be defined allowing + # for custom +KeyGenerator+ instances. If CBC encryption is used a + # `:raw_signed_key` or a `:signed_salt` option must also be defined. A + # +:digest+ may also be defined when using CBC encryption. This method can be + # called multiple times and new encryptor instances will be added to the + # rotation stack on each call. + # + # # Specifying the key used for encryption + # crypt.rotate raw_key: old_aead_key, cipher: "aes-256-gcm" + # crypt.rotate raw_key: old_cbc_key, raw_signed_key: old_cbc_sign_key, cipher: "aes-256-cbc", digest: "SHA1" + # + # # Using a KeyGenerator instance with a secret and salt(s) + # crypt.rotate secret: old_aead_secret, salt: old_aead_salt, cipher: "aes-256-gcm" + # crypt.rotate secret: old_cbc_secret, salt: old_cbc_salt, signed_salt: old_cbc_signed_salt, cipher: "aes-256-cbc", digest: "SHA1" + # + # # Specifying the key generator instance + # crypt.rotate key_generator: old_key_gen, salt: old_salt, cipher: "aes-256-gcm" class MessageEncryptor + prepend Messages::Rotator::Encryptor + class << self attr_accessor :use_authenticated_message_encryption #:nodoc: @@ -126,7 +156,7 @@ module ActiveSupport # Decrypt and verify a message. We need to verify the message in order to # avoid padding attacks. Reference: https://www.limited-entropy.com/padding-oracle-attacks/. - def decrypt_and_verify(data, purpose: nil) + def decrypt_and_verify(data, purpose: nil, **) _decrypt(verifier.verify(data), purpose) end diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb index 7110d6d2c9..0be13f6f03 100644 --- a/activesupport/lib/active_support/message_verifier.rb +++ b/activesupport/lib/active_support/message_verifier.rb @@ -4,6 +4,7 @@ require "base64" require_relative "core_ext/object/blank" require_relative "security_utils" require_relative "messages/metadata" +require_relative "messages/rotator" module ActiveSupport # +MessageVerifier+ makes it easy to generate and verify messages which are @@ -73,7 +74,36 @@ module ActiveSupport # Then the messages can be verified and returned upto the expire time. # Thereafter, the +verified+ method returns +nil+ while +verify+ raises # <tt>ActiveSupport::MessageVerifier::InvalidSignature</tt>. + # + # === Rotating keys + # + # This class also defines a +rotate+ method which can be used to rotate out + # verification keys no longer in use. + # + # This method is called with an options hash where a +:digest+ option and + # either a +:raw_key+ or +:secret+ option must be defined. If +:raw_key+ is + # defined, it is used directly for the underlying HMAC function. If the + # +:secret+ option is defined, a +:salt+ option must also be defined and a + # +KeyGenerator+ instance will be used to derive a key using +:salt+. When + # +:secret+ is used, a +:key_generator+ option may also be defined allowing + # for custom +KeyGenerator+ instances. This method can be called multiple + # times and new verifier instances will be added to the rotation stack on + # each call. + # + # # Specifying the key used for verification + # @verifier.rotate raw_key: older_key, digest: "SHA1" + # + # # Specify the digest + # @verifier.rotate raw_key: old_key, digest: "SHA256" + # + # # Using a KeyGenerator instance with a secret and salt + # @verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + # + # # Specifying the key generator instance + # @verifier.rotate key_generator: old_key_gen, salt: old_salt, digest: "SHA256" class MessageVerifier + prepend Messages::Rotator::Verifier + class InvalidSignature < StandardError; end def initialize(secret, options = {}) @@ -120,7 +150,7 @@ module ActiveSupport # # incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff" # verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format - def verified(signed_message, purpose: nil) + def verified(signed_message, purpose: nil, **) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] @@ -145,8 +175,8 @@ module ActiveSupport # # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' # other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature - def verify(signed_message, purpose: nil) - verified(signed_message, purpose: purpose) || raise(InvalidSignature) + def verify(*args) + verified(*args) || raise(InvalidSignature) end # Generates a signed message for the provided value. diff --git a/activesupport/lib/active_support/messages/rotation_configuration.rb b/activesupport/lib/active_support/messages/rotation_configuration.rb new file mode 100644 index 0000000000..12566bdb63 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotation_configuration.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + class RotationConfiguration + attr_reader :signed, :encrypted + + def initialize + @signed, @encrypted = [], [] + end + + def rotate(kind = nil, **options) + case kind + when :signed + @signed << options + when :encrypted + @encrypted << options + else + rotate :signed, options + rotate :encrypted, options + end + end + end + end +end diff --git a/activesupport/lib/active_support/messages/rotator.rb b/activesupport/lib/active_support/messages/rotator.rb new file mode 100644 index 0000000000..21ae643138 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotator.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + module Rotator # :nodoc: + def initialize(*args) + super + + @rotations = [] + end + + def rotate(*args) + @rotations << create_rotation(*args) + end + + module Encryptor + include Rotator + + def decrypt_and_verify(*args, on_rotation: nil, **options) + super + rescue MessageEncryptor::InvalidMessage, MessageVerifier::InvalidSignature + run_rotations(on_rotation) { |encryptor| encryptor.decrypt_and_verify(*args, options) } || raise + end + + private + def create_rotation(raw_key: nil, raw_signed_key: nil, **options) + self.class.new \ + raw_key || extract_key(options), + raw_signed_key || extract_signing_key(options), + options.slice(:cipher, :digest, :serializer) + end + + def extract_key(cipher:, salt:, key_generator: nil, secret: nil, **) + key_generator ||= key_generator_for(secret) + key_generator.generate_key(salt, self.class.key_len(cipher)) + end + + def extract_signing_key(cipher:, signed_salt: nil, key_generator: nil, secret: nil, **) + if cipher.downcase.end_with?("cbc") + raise ArgumentError, "missing signed_salt for signing key generation" unless signed_salt + + key_generator ||= key_generator_for(secret) + key_generator.generate_key(signed_salt) + end + end + end + + module Verifier + include Rotator + + def verified(*args, on_rotation: nil, **options) + super || run_rotations(on_rotation) { |verifier| verifier.verified(*args, options) } + end + + private + def create_rotation(raw_key: nil, digest: nil, serializer: nil, **options) + self.class.new(raw_key || extract_key(options), digest: digest, serializer: serializer) + end + + def extract_key(key_generator: nil, secret: nil, salt:) + key_generator ||= key_generator_for(secret) + key_generator.generate_key(salt) + end + end + + private + def key_generator_for(secret) + ActiveSupport::KeyGenerator.new(secret, iterations: 1000) + end + + def run_rotations(on_rotation) + @rotations.find do |rotation| + if message = yield(rotation) rescue next + on_rotation.call if on_rotation + return message + end + end + end + end + end +end diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index 1fbe655642..17baf3550b 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -115,6 +115,124 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message) end + def test_with_rotated_raw_key + old_raw_key = SecureRandom.random_bytes(32) + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw key") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate raw_key: old_raw_key, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old raw key", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_secret_and_salt + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt, 32) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old secret and salt") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secret: old_secret, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old secret and salt", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_key_generator + old_key_gen, old_salt = ActiveSupport::KeyGenerator.new(SecureRandom.random_bytes(32), iterations: 256), "old salt" + + old_raw_key = old_key_gen.generate_key(old_salt, 32) + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old key generator and salt") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate key_generator: old_key_gen, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old key generator and salt", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_aes_cbc_encryptor_with_raw_keys + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw keys") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "message encrypted with old raw keys", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotated_aes_cbc_encryptor_with_secret_and_salts + old_secret, old_salt, old_signed_salt = SecureRandom.random_bytes(32), "old salt", "old signed salt" + + old_key_gen = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000) + old_raw_key = old_key_gen.generate_key(old_salt, 32) + old_raw_signed_key = old_key_gen.generate_key(old_signed_salt) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old secret and salts") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate secret: old_secret, salt: old_salt, signed_salt: old_signed_salt, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "message encrypted with old secret and salts", encryptor.decrypt_and_verify(old_message) + end + + def test_with_rotating_multiple_encryptors + older_raw_key, older_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + older_encryptor = ActiveSupport::MessageEncryptor.new(older_raw_key, older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + older_message = older_encryptor.encrypt_and_sign("message encrypted with older raw key") + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + old_message = old_encryptor.encrypt_and_sign("message encrypted with old raw key") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + encryptor.rotate raw_key: older_raw_key, raw_signed_key: older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + assert_equal "encrypted message", encryptor.decrypt_and_verify(encryptor.encrypt_and_sign("encrypted message")) + assert_equal "message encrypted with old raw key", encryptor.decrypt_and_verify(old_message) + assert_equal "message encrypted with older raw key", encryptor.decrypt_and_verify(older_message) + end + + def test_on_rotation_instance_callback_is_called_and_returns_modified_messages + callback_ran, message = nil, nil + + older_raw_key, older_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + old_raw_key, old_raw_signed_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(16) + + older_encryptor = ActiveSupport::MessageEncryptor.new(older_raw_key, older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1") + older_message = older_encryptor.encrypt_and_sign(encoded: "message") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate raw_key: old_raw_key, raw_signed_key: old_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + encryptor.rotate raw_key: older_raw_key, raw_signed_key: older_raw_signed_key, cipher: "aes-256-cbc", digest: "SHA1" + + message = encryptor.decrypt_and_verify(older_message, on_rotation: proc { callback_ran = true }) + + assert callback_ran, "callback was ran" + assert_equal({ encoded: "message" }, message) + end + + def test_with_rotated_metadata + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt, 32) + + old_encryptor = ActiveSupport::MessageEncryptor.new(old_raw_key, cipher: "aes-256-gcm") + old_message = old_encryptor.encrypt_and_sign( + "message encrypted with old secret, salt, and metadata", purpose: "rotation") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secret: old_secret, salt: old_salt, cipher: "aes-256-gcm" + + assert_equal "message encrypted with old secret, salt, and metadata", + encryptor.decrypt_and_verify(old_message, purpose: "rotation") + end + private def assert_aead_not_decrypted(encryptor, value) assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do diff --git a/activesupport/test/message_verifier_test.rb b/activesupport/test/message_verifier_test.rb index fbeafca203..3079c48c02 100644 --- a/activesupport/test/message_verifier_test.rb +++ b/activesupport/test/message_verifier_test.rb @@ -20,6 +20,7 @@ class MessageVerifierTest < ActiveSupport::TestCase def setup @verifier = ActiveSupport::MessageVerifier.new("Hey, I'm a secret!") @data = { some: "data", now: Time.utc(2010) } + @secret = SecureRandom.random_bytes(32) end def test_valid_message @@ -90,6 +91,95 @@ class MessageVerifierTest < ActiveSupport::TestCase signed_message = "BAh7BzoJc29tZUkiCWRhdGEGOgZFVDoIbm93SXU6CVRpbWUNIIAbgAAAAAAHOgtvZmZzZXRpADoJem9uZUkiCFVUQwY7BkY=--d03c52c91dfe4ccc5159417c660461bcce005e96" assert_equal @data, @verifier.verify(signed_message) end + + def test_with_rotated_raw_key + old_raw_key = SecureRandom.random_bytes(32) + + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old raw key") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate raw_key: old_raw_key, digest: "SHA1" + + assert_equal "message verified with old raw key", verifier.verified(old_message) + end + + def test_with_rotated_secret_and_salt + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old secret and salt") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old secret and salt", verifier.verified(old_message) + end + + def test_with_rotated_key_generator + old_key_gen, old_salt = ActiveSupport::KeyGenerator.new(SecureRandom.random_bytes(32), iterations: 256), "old salt" + + old_raw_key = old_key_gen.generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate("message verified with old key generator and salt") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate key_generator: old_key_gen, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old key generator and salt", verifier.verified(old_message) + end + + def test_with_rotating_multiple_verifiers + old_raw_key, older_raw_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(32) + + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA256") + old_message = old_verifier.generate("message verified with old raw key") + + older_verifier = ActiveSupport::MessageVerifier.new(older_raw_key, digest: "SHA1") + older_message = older_verifier.generate("message verified with older raw key") + + verifier = ActiveSupport::MessageVerifier.new("new secret", digest: "SHA512") + verifier.rotate raw_key: old_raw_key, digest: "SHA256" + verifier.rotate raw_key: older_raw_key, digest: "SHA1" + + assert_equal "verified message", verifier.verified(verifier.generate("verified message")) + assert_equal "message verified with old raw key", verifier.verified(old_message) + assert_equal "message verified with older raw key", verifier.verified(older_message) + end + + def test_on_rotation_keyword_block_is_called_and_verified_returns_message + callback_ran, message = nil, nil + + old_raw_key, older_raw_key = SecureRandom.random_bytes(32), SecureRandom.random_bytes(32) + + older_verifier = ActiveSupport::MessageVerifier.new(older_raw_key, digest: "SHA1") + older_message = older_verifier.generate(encoded: "message") + + verifier = ActiveSupport::MessageVerifier.new("new secret", digest: "SHA512") + verifier.rotate raw_key: old_raw_key, digest: "SHA256" + verifier.rotate raw_key: older_raw_key, digest: "SHA1" + + message = verifier.verified(older_message, on_rotation: proc { callback_ran = true }) + + assert callback_ran, "callback was ran" + assert_equal({ encoded: "message" }, message) + end + + def test_with_rotated_metadata + old_secret, old_salt = SecureRandom.random_bytes(32), "old salt" + + old_raw_key = ActiveSupport::KeyGenerator.new(old_secret, iterations: 1000).generate_key(old_salt) + old_verifier = ActiveSupport::MessageVerifier.new(old_raw_key, digest: "SHA1") + old_message = old_verifier.generate( + "message verified with old secret, salt, and metadata", purpose: "rotation") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate secret: old_secret, salt: old_salt, digest: "SHA1" + + assert_equal "message verified with old secret, salt, and metadata", + verifier.verified(old_message, purpose: "rotation") + end end class MessageVerifierMetadataTest < ActiveSupport::TestCase diff --git a/activesupport/test/messages/rotation_configuration_test.rb b/activesupport/test/messages/rotation_configuration_test.rb new file mode 100644 index 0000000000..41d938e119 --- /dev/null +++ b/activesupport/test/messages/rotation_configuration_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/messages/rotation_configuration" + +class MessagesRotationConfiguration < ActiveSupport::TestCase + def setup + @config = ActiveSupport::Messages::RotationConfiguration.new + end + + def test_signed_configurations + @config.rotate :signed, secret: "older secret", salt: "salt", digest: "SHA1" + @config.rotate :signed, secret: "old secret", salt: "salt", digest: "SHA256" + + assert_equal [{ + secret: "older secret", salt: "salt", digest: "SHA1" + }, { + secret: "old secret", salt: "salt", digest: "SHA256" + }], @config.signed + end + + def test_encrypted_configurations + @config.rotate :encrypted, raw_key: "old raw key", cipher: "aes-256-gcm" + + assert_equal [{ + raw_key: "old raw key", cipher: "aes-256-gcm" + }], @config.encrypted + end + + def test_rotate_without_kind + @config.rotate secret: "older secret", salt: "salt", digest: "SHA1" + @config.rotate raw_key: "old raw key", cipher: "aes-256-gcm" + + expected = [{ + secret: "older secret", salt: "salt", digest: "SHA1" + }, { + raw_key: "old raw key", cipher: "aes-256-gcm" + }] + + assert_equal expected, @config.encrypted + assert_equal expected, @config.signed + end +end diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index abfec90b6d..f691156921 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -259,8 +259,11 @@ module Rails "action_dispatch.encrypted_cookie_salt" => config.action_dispatch.encrypted_cookie_salt, "action_dispatch.encrypted_signed_cookie_salt" => config.action_dispatch.encrypted_signed_cookie_salt, "action_dispatch.authenticated_encrypted_cookie_salt" => config.action_dispatch.authenticated_encrypted_cookie_salt, + "action_dispatch.encrypted_cookie_cipher" => config.action_dispatch.encrypted_cookie_cipher, + "action_dispatch.signed_cookie_digest" => config.action_dispatch.signed_cookie_digest, "action_dispatch.cookies_serializer" => config.action_dispatch.cookies_serializer, - "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest + "action_dispatch.cookies_digest" => config.action_dispatch.cookies_digest, + "action_dispatch.cookies_rotations" => config.action_dispatch.cookies_rotations ) end end diff --git a/railties/test/application/middleware/cookies_test.rb b/railties/test/application/middleware/cookies_test.rb index 23f1ec3e35..932a6d0e77 100644 --- a/railties/test/application/middleware/cookies_test.rb +++ b/railties/test/application/middleware/cookies_test.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require "isolation/abstract_unit" +require "rack/test" module ApplicationTests class CookiesTest < ActiveSupport::TestCase include ActiveSupport::Testing::Isolation + include Rack::Test::Methods def new_app File.expand_path("#{app_path}/../new_app") @@ -15,6 +17,10 @@ module ApplicationTests FileUtils.rm_rf("#{app_path}/config/environments") end + def app + Rails.application + end + def teardown teardown_app FileUtils.rm_rf(new_app) if File.directory?(new_app) @@ -44,5 +50,142 @@ module ApplicationTests require "#{app_path}/config/environment" assert_equal false, ActionDispatch::Cookies::CookieJar.always_write_cookie end + + test "signed cookies with SHA512 digest and rotated out SHA256 and SHA1 digests" do + key_gen_sha1 = ActiveSupport::KeyGenerator.new("legacy sha1 secret", iterations: 1000) + key_gen_sha256 = ActiveSupport::KeyGenerator.new("legacy sha256 secret", iterations: 1000) + + verifer_sha1 = ActiveSupport::MessageVerifier.new(key_gen_sha1.generate_key("sha1 salt"), digest: :SHA1) + verifer_sha256 = ActiveSupport::MessageVerifier.new(key_gen_sha256.generate_key("sha256 salt"), digest: :SHA256) + + app_file "config/routes.rb", <<-RUBY + Rails.application.routes.draw do + get ':controller(/:action)' + post ':controller(/:action)' + end + RUBY + + controller :foo, <<-RUBY + class FooController < ActionController::Base + protect_from_forgery with: :null_session + + def write_raw_cookie_sha1 + cookies[:signed_cookie] = "#{verifer_sha1.generate("signed cookie")}" + head :ok + end + + def write_raw_cookie_sha256 + cookies[:signed_cookie] = "#{verifer_sha256.generate("signed cookie")}" + head :ok + end + + def read_signed + render plain: cookies.signed[:signed_cookie].inspect + end + + def read_raw_cookie + render plain: cookies[:signed_cookie] + end + end + RUBY + + add_to_config <<-RUBY + config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA1", secret: "legacy sha1 secret", salt: "sha1 salt" + + config.action_dispatch.cookies_rotations.rotate :signed, + digest: "SHA256", secret: "legacy sha256 secret", salt: "sha256 salt" + + config.action_dispatch.signed_cookie_digest = "SHA512" + config.action_dispatch.signed_cookie_salt = "sha512 salt" + RUBY + + require "#{app_path}/config/environment" + + verifer_sha512 = ActiveSupport::MessageVerifier.new(app.key_generator.generate_key("sha512 salt"), digest: :SHA512) + + get "/foo/write_raw_cookie_sha1" + get "/foo/read_signed" + assert_equal "signed cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "signed cookie", verifer_sha512.verify(last_response.body) + + get "/foo/write_raw_cookie_sha256" + get "/foo/read_signed" + assert_equal "signed cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "signed cookie", verifer_sha512.verify(last_response.body) + end + + test "encrypted cookies with multiple rotated out ciphers" do + key_gen_one = ActiveSupport::KeyGenerator.new("legacy secret one", iterations: 1000) + key_gen_two = ActiveSupport::KeyGenerator.new("legacy secret two", iterations: 1000) + + encryptor_one = ActiveSupport::MessageEncryptor.new(key_gen_one.generate_key("salt one", 32), cipher: "aes-256-gcm") + encryptor_two = ActiveSupport::MessageEncryptor.new(key_gen_two.generate_key("salt two", 32), cipher: "aes-256-gcm") + + app_file "config/routes.rb", <<-RUBY + Rails.application.routes.draw do + get ':controller(/:action)' + post ':controller(/:action)' + end + RUBY + + controller :foo, <<-RUBY + class FooController < ActionController::Base + protect_from_forgery with: :null_session + + def write_raw_cookie_one + cookies[:encrypted_cookie] = "#{encryptor_one.encrypt_and_sign("encrypted cookie")}" + head :ok + end + + def write_raw_cookie_two + cookies[:encrypted_cookie] = "#{encryptor_two.encrypt_and_sign("encrypted cookie")}" + head :ok + end + + def read_encrypted + render plain: cookies.encrypted[:encrypted_cookie].inspect + end + + def read_raw_cookie + render plain: cookies[:encrypted_cookie] + end + end + RUBY + + add_to_config <<-RUBY + config.action_dispatch.use_authenticated_cookie_encryption = true + config.action_dispatch.encrypted_cookie_cipher = "aes-256-gcm" + config.action_dispatch.authenticated_encrypted_cookie_salt = "salt" + + config.action_dispatch.cookies_rotations.rotate :encrypted, + cipher: "aes-256-gcm", secret: "legacy secret one", salt: "salt one" + + config.action_dispatch.cookies_rotations.rotate :encrypted, + cipher: "aes-256-gcm", secret: "legacy secret two", salt: "salt two" + RUBY + + require "#{app_path}/config/environment" + + encryptor = ActiveSupport::MessageEncryptor.new(app.key_generator.generate_key("salt", 32), cipher: "aes-256-gcm") + + get "/foo/write_raw_cookie_one" + get "/foo/read_encrypted" + assert_equal "encrypted cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) + + get "/foo/write_raw_cookie_sha256" + get "/foo/read_encrypted" + assert_equal "encrypted cookie".inspect, last_response.body + + get "/foo/read_raw_cookie" + assert_equal "encrypted cookie", encryptor.decrypt_and_verify(last_response.body) + end end end |