diff options
Diffstat (limited to 'activesupport')
17 files changed, 342 insertions, 32 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index f158d5357d..493ebeb01f 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,22 @@ +* Return an instance of `HashWithIndifferentAccess` from `HashWithIndifferentAccess#transform_keys`. + + *Yuji Yaginuma* + +* 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* + * Add `config/credentials.yml.enc` to store production app secrets. Allows saving any authentication credentials for third party services diff --git a/activesupport/lib/active_support/core_ext/class/subclasses.rb b/activesupport/lib/active_support/core_ext/class/subclasses.rb index 4c910feb44..75e65337b7 100644 --- a/activesupport/lib/active_support/core_ext/class/subclasses.rb +++ b/activesupport/lib/active_support/core_ext/class/subclasses.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require_relative "../module/anonymous" -require_relative "../module/reachable" - class Class begin # Test if this Ruby supports each_object against singleton_class diff --git a/activesupport/lib/active_support/core_ext/module/reachable.rb b/activesupport/lib/active_support/core_ext/module/reachable.rb index 91b230b46c..790a3cc561 100644 --- a/activesupport/lib/active_support/core_ext/module/reachable.rb +++ b/activesupport/lib/active_support/core_ext/module/reachable.rb @@ -7,4 +7,5 @@ class Module def reachable? #:nodoc: !anonymous? && name.safe_constantize.equal?(self) end + deprecate :reachable? end diff --git a/activesupport/lib/active_support/hash_with_indifferent_access.rb b/activesupport/lib/active_support/hash_with_indifferent_access.rb index 12291af443..fcc13feb8c 100644 --- a/activesupport/lib/active_support/hash_with_indifferent_access.rb +++ b/activesupport/lib/active_support/hash_with_indifferent_access.rb @@ -306,6 +306,11 @@ module ActiveSupport dup.tap { |hash| hash.transform_values!(*args, &block) } end + def transform_keys(*args, &block) + return to_enum(:transform_keys) unless block_given? + dup.tap { |hash| hash.transform_keys!(*args, &block) } + end + def compact dup.tap(&:compact!) end diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index 27620f56be..8a1918039c 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -54,7 +54,33 @@ module ActiveSupport # # Then the messages can be verified and returned upto the expire time. # Thereafter, verifying returns +nil+. + # + # === Rotating keys + # + # MessageEncryptor also supports rotating out old configurations by falling + # back to a stack of encryptors. Call `rotate` to build and add an encryptor + # so `decrypt_and_verify` will also try the fallback. + # + # By default any rotated encryptors use the values of the primary + # encryptor unless specified otherwise. + # + # You'd give your encryptor the new defaults: + # + # crypt = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + # + # Then gradually rotate the old values out by adding them as fallbacks. Any message + # generated with the old values will then work until the rotation is removed. + # + # crypt.rotate old_secret # Fallback to an old secret instead of @secret. + # crypt.rotate cipher: "aes-256-cbc" # Fallback to an old cipher instead of aes-256-gcm. + # + # Though if both the secret and the cipher was changed at the same time, + # the above should be combined into: + # + # verifier.rotate old_secret, cipher: "aes-256-cbc" class MessageEncryptor + prepend Messages::Rotator::Encryptor + class << self attr_accessor :use_authenticated_message_encryption #:nodoc: @@ -126,7 +152,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..f0b6503b96 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,33 @@ 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 + # + # MessageVerifier also supports rotating out old configurations by falling + # back to a stack of verifiers. Call `rotate` to build and add a verifier to + # so either `verified` or `verify` will also try verifying with the fallback. + # + # By default any rotated verifiers use the values of the primary + # verifier unless specified otherwise. + # + # You'd give your verifier the new defaults: + # + # verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA512", serializer: JSON) + # + # Then gradually rotate the old values out by adding them as fallbacks. Any message + # generated with the old values will then work until the rotation is removed. + # + # verifier.rotate old_secret # Fallback to an old secret instead of @secret. + # verifier.rotate digest: "SHA256" # Fallback to an old digest instead of SHA512. + # verifier.rotate serializer: Marshal # Fallback to an old serializer instead of JSON. + # + # Though the above would most likely be combined into one rotation: + # + # verifier.rotate old_secret, digest: "SHA256", serializer: Marshal class MessageVerifier + prepend Messages::Rotator::Verifier + class InvalidSignature < StandardError; end def initialize(secret, options = {}) @@ -120,7 +147,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 +172,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..bd50d6d348 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotation_configuration.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + class RotationConfiguration # :nodoc: + attr_reader :signed, :encrypted + + def initialize + @signed, @encrypted = [], [] + end + + def rotate(kind, *args) + case kind + when :signed + @signed << args + when :encrypted + @encrypted << args + 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..823a399d67 --- /dev/null +++ b/activesupport/lib/active_support/messages/rotator.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ActiveSupport + module Messages + module Rotator # :nodoc: + def initialize(*, **options) + super + + @options = options + @rotations = [] + end + + def rotate(*secrets, **options) + @rotations << build_rotation(*secrets, @options.merge(options)) + 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 build_rotation(secret = @secret, sign_secret = @sign_secret, options) + self.class.new(secret, sign_secret, options) + 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 build_rotation(secret = @secret, options) + self.class.new(secret, options) + end + end + + private + 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/lib/active_support/number_helper/number_to_rounded_converter.rb b/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb index 3b62fe6819..b7ad76bb62 100644 --- a/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb +++ b/activesupport/lib/active_support/number_helper/number_to_rounded_converter.rb @@ -37,18 +37,6 @@ module ActiveSupport private - def digits_and_rounded_number(precision) - if zero? - [1, 0] - else - digits = digit_count(number) - multiplier = 10**(digits - precision) - rounded_number = calculate_rounded_number(multiplier) - digits = digit_count(rounded_number) # After rounding, the number of digits may have changed - [digits, rounded_number] - end - end - def calculate_rounded_number(multiplier) (number / BigDecimal.new(multiplier.to_f.to_s)).round * multiplier end diff --git a/activesupport/lib/active_support/ordered_options.rb b/activesupport/lib/active_support/ordered_options.rb index fa7825b3ba..b74510fdb2 100644 --- a/activesupport/lib/active_support/ordered_options.rb +++ b/activesupport/lib/active_support/ordered_options.rb @@ -24,7 +24,7 @@ module ActiveSupport # To raise an exception when the value is blank, append a # bang to the key name, like: # - # h.dog! # => raises KeyError: key not found: :dog + # h.dog! # => raises KeyError: :dog is blank # class OrderedOptions < Hash alias_method :_get, :[] # preserve the original #[] method @@ -46,7 +46,7 @@ module ActiveSupport bangs = name_string.chomp!("!") if bangs - fetch(name_string.to_sym).presence || raise(KeyError.new("#{name_string} is blank.")) + self[name_string].presence || raise(KeyError.new(":#{name_string} is blank")) else self[name_string] end diff --git a/activesupport/lib/active_support/security_utils.rb b/activesupport/lib/active_support/security_utils.rb index 51870559ec..b6b31ef140 100644 --- a/activesupport/lib/active_support/security_utils.rb +++ b/activesupport/lib/active_support/security_utils.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "digest" +require "digest/sha2" module ActiveSupport module SecurityUtils diff --git a/activesupport/test/core_ext/module/reachable_test.rb b/activesupport/test/core_ext/module/reachable_test.rb index a69fc6839e..097a72fa5b 100644 --- a/activesupport/test/core_ext/module/reachable_test.rb +++ b/activesupport/test/core_ext/module/reachable_test.rb @@ -5,13 +5,17 @@ require "active_support/core_ext/module/reachable" class AnonymousTest < ActiveSupport::TestCase test "an anonymous class or module is not reachable" do - assert !Module.new.reachable? - assert !Class.new.reachable? + assert_deprecated do + assert !Module.new.reachable? + assert !Class.new.reachable? + end end test "ordinary named classes or modules are reachable" do - assert Kernel.reachable? - assert Object.reachable? + assert_deprecated do + assert Kernel.reachable? + assert Object.reachable? + end end test "a named class or module whose constant has gone is not reachable" do @@ -21,8 +25,10 @@ class AnonymousTest < ActiveSupport::TestCase self.class.send(:remove_const, :C) self.class.send(:remove_const, :M) - assert !c.reachable? - assert !m.reachable? + assert_deprecated do + assert !c.reachable? + assert !m.reachable? + end end test "a named class or module whose constants store different objects are not reachable" do @@ -35,9 +41,11 @@ class AnonymousTest < ActiveSupport::TestCase eval "class C; end" eval "module M; end" - assert C.reachable? - assert M.reachable? - assert !c.reachable? - assert !m.reachable? + assert_deprecated do + assert C.reachable? + assert M.reachable? + assert !c.reachable? + assert !m.reachable? + end end end diff --git a/activesupport/test/hash_with_indifferent_access_test.rb b/activesupport/test/hash_with_indifferent_access_test.rb index b3788ee65c..b878ac20fa 100644 --- a/activesupport/test/hash_with_indifferent_access_test.rb +++ b/activesupport/test/hash_with_indifferent_access_test.rb @@ -399,6 +399,13 @@ class HashWithIndifferentAccessTest < ActiveSupport::TestCase assert_instance_of ActiveSupport::HashWithIndifferentAccess, indifferent_strings end + def test_indifferent_transform_keys + hash = ActiveSupport::HashWithIndifferentAccess.new(@strings).transform_keys { |k| k * 2 } + + assert_equal({ "aa" => 1, "bb" => 2 }, hash) + assert_instance_of ActiveSupport::HashWithIndifferentAccess, hash + end + def test_indifferent_compact hash_contain_nil_value = @strings.merge("z" => nil) hash = ActiveSupport::HashWithIndifferentAccess.new(hash_contain_nil_value) diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index 1fbe655642..9edf07f762 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -115,6 +115,72 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message) end + def test_rotating_secret + old_message = ActiveSupport::MessageEncryptor.new(secrets[:old], cipher: "aes-256-gcm").encrypt_and_sign("old") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secrets[:old] + + assert_equal "old", encryptor.decrypt_and_verify(old_message) + end + + def test_rotating_serializer + old_message = ActiveSupport::MessageEncryptor.new(secrets[:old], cipher: "aes-256-gcm", serializer: JSON). + encrypt_and_sign(ahoy: :hoy) + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm", serializer: JSON) + encryptor.rotate secrets[:old] + + assert_equal({ "ahoy" => "hoy" }, encryptor.decrypt_and_verify(old_message)) + end + + def test_rotating_aes_cbc_secrets + old_encryptor = ActiveSupport::MessageEncryptor.new(secrets[:old], "old sign", cipher: "aes-256-cbc") + old_message = old_encryptor.encrypt_and_sign("old") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate secrets[:old], "old sign", cipher: "aes-256-cbc" + + assert_equal "old", encryptor.decrypt_and_verify(old_message) + end + + def test_multiple_rotations + older_message = ActiveSupport::MessageEncryptor.new(secrets[:older], "older sign").encrypt_and_sign("older") + old_message = ActiveSupport::MessageEncryptor.new(secrets[:old], "old sign").encrypt_and_sign("old") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate secrets[:old], "old sign" + encryptor.rotate secrets[:older], "older sign" + + assert_equal "new", encryptor.decrypt_and_verify(encryptor.encrypt_and_sign("new")) + assert_equal "old", encryptor.decrypt_and_verify(old_message) + assert_equal "older", encryptor.decrypt_and_verify(older_message) + end + + def test_on_rotation_is_called_and_returns_modified_messages + older_message = ActiveSupport::MessageEncryptor.new(secrets[:older], "older sign").encrypt_and_sign(encoded: "message") + + encryptor = ActiveSupport::MessageEncryptor.new(@secret) + encryptor.rotate secrets[:old] + encryptor.rotate secrets[:older], "older sign" + + rotated = false + message = encryptor.decrypt_and_verify(older_message, on_rotation: proc { rotated = true }) + + assert_equal({ encoded: "message" }, message) + assert rotated + end + + def test_with_rotated_metadata + old_message = ActiveSupport::MessageEncryptor.new(secrets[:old], cipher: "aes-256-gcm"). + encrypt_and_sign("metadata", purpose: :rotation) + + encryptor = ActiveSupport::MessageEncryptor.new(@secret, cipher: "aes-256-gcm") + encryptor.rotate secrets[:old] + + assert_equal "metadata", encryptor.decrypt_and_verify(old_message, purpose: :rotation) + end + private def assert_aead_not_decrypted(encryptor, value) assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do @@ -134,6 +200,10 @@ class MessageEncryptorTest < ActiveSupport::TestCase end end + def secrets + @secrets ||= Hash.new { |h, k| h[k] = SecureRandom.random_bytes(32) } + end + def munge(base64_string) bits = ::Base64.strict_decode64(base64_string) bits.reverse! diff --git a/activesupport/test/message_verifier_test.rb b/activesupport/test/message_verifier_test.rb index fbeafca203..05d5c1cbc3 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,51 @@ class MessageVerifierTest < ActiveSupport::TestCase signed_message = "BAh7BzoJc29tZUkiCWRhdGEGOgZFVDoIbm93SXU6CVRpbWUNIIAbgAAAAAAHOgtvZmZzZXRpADoJem9uZUkiCFVUQwY7BkY=--d03c52c91dfe4ccc5159417c660461bcce005e96" assert_equal @data, @verifier.verify(signed_message) end + + def test_rotating_secret + old_message = ActiveSupport::MessageVerifier.new("old", digest: "SHA1").generate("old") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA1") + verifier.rotate "old" + + assert_equal "old", verifier.verified(old_message) + end + + def test_multiple_rotations + old_message = ActiveSupport::MessageVerifier.new("old", digest: "SHA256").generate("old") + older_message = ActiveSupport::MessageVerifier.new("older", digest: "SHA1").generate("older") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA512") + verifier.rotate "old", digest: "SHA256" + verifier.rotate "older", digest: "SHA1" + + assert_equal "new", verifier.verified(verifier.generate("new")) + assert_equal "old", verifier.verified(old_message) + assert_equal "older", verifier.verified(older_message) + end + + def test_on_rotation_is_called_and_verified_returns_message + older_message = ActiveSupport::MessageVerifier.new("older", digest: "SHA1").generate(encoded: "message") + + verifier = ActiveSupport::MessageVerifier.new(@secret, digest: "SHA512") + verifier.rotate "old", digest: "SHA256" + verifier.rotate "older", digest: "SHA1" + + rotated = false + message = verifier.verified(older_message, on_rotation: proc { rotated = true }) + + assert_equal({ encoded: "message" }, message) + assert rotated + end + + def test_rotations_with_metadata + old_message = ActiveSupport::MessageVerifier.new("old").generate("old", purpose: :rotation) + + verifier = ActiveSupport::MessageVerifier.new(@secret) + verifier.rotate "old" + + assert_equal "old", 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..2f6824ed21 --- /dev/null +++ b/activesupport/test/messages/rotation_configuration_test.rb @@ -0,0 +1,25 @@ +# 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, "older secret", salt: "salt", digest: "SHA1" + @config.rotate :signed, "old secret", salt: "salt", digest: "SHA256" + + assert_equal [ + [ "older secret", salt: "salt", digest: "SHA1" ], + [ "old secret", salt: "salt", digest: "SHA256" ] ], @config.signed + end + + def test_encrypted_configurations + @config.rotate :encrypted, "old raw key", cipher: "aes-256-gcm" + + assert_equal [ [ "old raw key", cipher: "aes-256-gcm" ] ], @config.encrypted + end +end diff --git a/activesupport/test/ordered_options_test.rb b/activesupport/test/ordered_options_test.rb index 7f2e774c02..2c67bb02ac 100644 --- a/activesupport/test/ordered_options_test.rb +++ b/activesupport/test/ordered_options_test.rb @@ -102,4 +102,17 @@ class OrderedOptionsTest < ActiveSupport::TestCase end assert_raises(KeyError) { a.non_existing_key! } end + + def test_inheritable_options_with_bang + a = ActiveSupport::InheritableOptions.new(foo: :bar) + + assert_nothing_raised { a.foo! } + assert_equal a.foo, a.foo! + + assert_raises(KeyError) do + a.foo = nil + a.foo! + end + assert_raises(KeyError) { a.non_existing_key! } + end end |