diff options
Diffstat (limited to 'activesupport')
-rw-r--r-- | activesupport/CHANGELOG.md | 22 | ||||
-rw-r--r-- | activesupport/lib/active_support/message_encryptor.rb | 43 | ||||
-rw-r--r-- | activesupport/lib/active_support/message_verifier.rb | 53 | ||||
-rw-r--r-- | activesupport/lib/active_support/messages/metadata.rb | 55 | ||||
-rw-r--r-- | activesupport/lib/active_support/testing/time_helpers.rb | 9 | ||||
-rw-r--r-- | activesupport/test/message_encryptor_test.rb | 44 | ||||
-rw-r--r-- | activesupport/test/message_verifier_test.rb | 58 | ||||
-rw-r--r-- | activesupport/test/metadata/shared_metadata_tests.rb | 88 | ||||
-rw-r--r-- | activesupport/test/time_zone_test.rb | 3 |
9 files changed, 358 insertions, 17 deletions
diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index f5542c6d25..457eb84a62 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,25 @@ +* Add purpose and expiry support to `ActiveSupport::MessageVerifier` & + `ActiveSupport::MessageEncryptor`. + + For instance, to ensure a message is only usable for one intended purpose: + + token = @verifier.generate("x", purpose: :shipping) + + @verifier.verified(token, purpose: :shipping) # => "x" + @verifier.verified(token) # => nil + + Or make it expire after a set time: + + @verifier.generate("x", expires_in: 1.month) + @verifier.generate("y", expires_at: Time.now.end_of_year) + + Showcased with `ActiveSupport::MessageVerifier`, but works the same for + `ActiveSupport::MessageEncryptor`'s `encrypt_and_sign` and `decrypt_and_verify`. + + Pull requests: #29599, #29854 + + *Assain Jaleel* + * Make the order of `Hash#reverse_merge!` consistent with `HashWithIndifferentAccess`. *Erol Fornoles* diff --git a/activesupport/lib/active_support/message_encryptor.rb b/activesupport/lib/active_support/message_encryptor.rb index d5db2920b9..090d51933a 100644 --- a/activesupport/lib/active_support/message_encryptor.rb +++ b/activesupport/lib/active_support/message_encryptor.rb @@ -4,6 +4,7 @@ require "openssl" require "base64" require_relative "core_ext/array/extract_options" require_relative "message_verifier" +require_relative "messages/metadata" module ActiveSupport # MessageEncryptor is a simple way to encrypt values which get stored @@ -21,6 +22,38 @@ module ActiveSupport # crypt = ActiveSupport::MessageEncryptor.new(key) # => #<ActiveSupport::MessageEncryptor ...> # encrypted_data = crypt.encrypt_and_sign('my secret data') # => "NlFBTTMwOUV5UlA1QlNEN2xkY2d6eThYWWh..." # crypt.decrypt_and_verify(encrypted_data) # => "my secret data" + # + # === Confining messages to a specific purpose + # + # By default any message can be used throughout your app. But they can also be + # confined to a specific +:purpose+. + # + # token = crypt.encrypt_and_sign("this is the chair", purpose: :login) + # + # Then that same purpose must be passed when verifying to get the data back out: + # + # crypt.decrypt_and_verify(token, purpose: :login) # => "this is the chair" + # crypt.decrypt_and_verify(token, purpose: :shipping) # => nil + # crypt.decrypt_and_verify(token) # => nil + # + # Likewise, if a message has no purpose it won't be returned when verifying with + # a specific purpose. + # + # token = crypt.encrypt_and_sign("the conversation is lively") + # crypt.decrypt_and_verify(token, purpose: :scare_tactics) # => nil + # crypt.decrypt_and_verify(token) # => "the conversation is lively" + # + # === Making messages expire + # + # By default messages last forever and verifying one year from now will still + # return the original value. But messages can be set to expire at a given + # time with +:expires_in+ or +:expires_at+. + # + # crypt.encrypt_and_sign(parcel, expires_in: 1.month) + # crypt.encrypt_and_sign(doowad, expires_at: Time.now.end_of_year) + # + # Then the messages can be verified and returned upto the expire time. + # Thereafter, verifying returns +nil+. class MessageEncryptor class << self attr_accessor :use_authenticated_message_encryption #:nodoc: @@ -87,14 +120,15 @@ module ActiveSupport # Encrypt and sign a message. We need to sign the message in order to avoid # padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks. - def encrypt_and_sign(value) - verifier.generate(_encrypt(value)) + def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil) + data = Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose) + verifier.generate(_encrypt(data)) end # Decrypt and verify a message. We need to verify the message in order to # avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks. - def decrypt_and_verify(value) - _decrypt(verifier.verify(value)) + def decrypt_and_verify(data, purpose: nil) + Messages::Metadata.verify(_decrypt(verifier.verify(data)), purpose) end # Given a cipher, returns the key length of the cipher to help generate the key of desired size @@ -103,7 +137,6 @@ module ActiveSupport end private - def _encrypt(value) cipher = new_cipher cipher.encrypt diff --git a/activesupport/lib/active_support/message_verifier.rb b/activesupport/lib/active_support/message_verifier.rb index b889f31f7a..fdd2185f7f 100644 --- a/activesupport/lib/active_support/message_verifier.rb +++ b/activesupport/lib/active_support/message_verifier.rb @@ -3,6 +3,7 @@ require "base64" require_relative "core_ext/object/blank" require_relative "security_utils" +require_relative "messages/metadata" module ActiveSupport # +MessageVerifier+ makes it easy to generate and verify messages which are @@ -32,6 +33,46 @@ module ActiveSupport # `:digest` key as an option while initializing the verifier: # # @verifier = ActiveSupport::MessageVerifier.new('s3Krit', digest: 'SHA256') + # + # === Confining messages to a specific purpose + # + # By default any message can be used throughout your app. But they can also be + # confined to a specific +:purpose+. + # + # token = @verifier.generate("this is the chair", purpose: :login) + # + # Then that same purpose must be passed when verifying to get the data back out: + # + # @verifier.verified(token, purpose: :login) # => "this is the chair" + # @verifier.verified(token, purpose: :shipping) # => nil + # @verifier.verified(token) # => nil + # + # @verifier.verify(token, purpose: :login) # => "this is the chair" + # @verifier.verify(token, purpose: :shipping) # => ActiveSupport::MessageVerifier::InvalidSignature + # @verifier.verify(token) # => ActiveSupport::MessageVerifier::InvalidSignature + # + # Likewise, if a message has no purpose it won't be returned when verifying with + # a specific purpose. + # + # token = @verifier.generate("the conversation is lively") + # @verifier.verified(token, purpose: :scare_tactics) # => nil + # @verifier.verified(token) # => "the conversation is lively" + # + # @verifier.verify(token, purpose: :scare_tactics) # => ActiveSupport::MessageVerifier::InvalidSignature + # @verifier.verify(token) # => "the conversation is lively" + # + # === Making messages expire + # + # By default messages last forever and verifying one year from now will still + # return the original value. But messages can be set to expire at a given + # time with +:expires_in+ or +:expires_at+. + # + # @verifier.generate(parcel, expires_in: 1.month) + # @verifier.generate(doowad, expires_at: Time.now.end_of_year) + # + # 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>. class MessageVerifier class InvalidSignature < StandardError; end @@ -79,11 +120,11 @@ module ActiveSupport # # incompatible_message = "test--dad7b06c94abba8d46a15fafaef56c327665d5ff" # verifier.verified(incompatible_message) # => TypeError: incompatible marshal file format - def verified(signed_message) + def verified(signed_message, purpose: nil) if valid_message?(signed_message) begin data = signed_message.split("--".freeze)[0] - @serializer.load(decode(data)) + Messages::Metadata.verify(@serializer.load(decode(data)), purpose) rescue ArgumentError => argument_error return if argument_error.message.include?("invalid base64") raise @@ -103,8 +144,8 @@ module ActiveSupport # # other_verifier = ActiveSupport::MessageVerifier.new 'd1ff3r3nt-s3Krit' # other_verifier.verify(signed_message) # => ActiveSupport::MessageVerifier::InvalidSignature - def verify(signed_message) - verified(signed_message) || raise(InvalidSignature) + def verify(signed_message, purpose: nil) + verified(signed_message, purpose: purpose) || raise(InvalidSignature) end # Generates a signed message for the provided value. @@ -114,8 +155,8 @@ module ActiveSupport # # verifier = ActiveSupport::MessageVerifier.new 's3Krit' # verifier.generate 'a private message' # => "BAhJIhRwcml2YXRlLW1lc3NhZ2UGOgZFVA==--e2d724331ebdee96a10fb99b089508d1c72bd772" - def generate(value) - data = encode(@serializer.dump(value)) + def generate(value, expires_at: nil, expires_in: nil, purpose: nil) + data = encode(@serializer.dump(Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose))) "#{data}--#{generate_digest(data)}" end diff --git a/activesupport/lib/active_support/messages/metadata.rb b/activesupport/lib/active_support/messages/metadata.rb new file mode 100644 index 0000000000..db14ac0b1c --- /dev/null +++ b/activesupport/lib/active_support/messages/metadata.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require "time" + +module ActiveSupport + module Messages #:nodoc: + class Metadata #:nodoc: + def initialize(expires_at, purpose) + @expires_at, @purpose = expires_at, purpose.to_s + end + + class << self + def wrap(message, expires_at: nil, expires_in: nil, purpose: nil) + if expires_at || expires_in || purpose + { "value" => message, "_rails" => { "exp" => pick_expiry(expires_at, expires_in), "pur" => purpose } } + else + message + end + end + + def verify(message, purpose) + metadata = extract_metadata(message) + + if metadata.nil? + message if purpose.nil? + elsif metadata.match?(purpose) && metadata.fresh? + message["value"] + end + end + + private + def pick_expiry(expires_at, expires_in) + if expires_at + expires_at.utc.iso8601(3) + elsif expires_in + Time.now.utc.advance(seconds: expires_in).iso8601(3) + end + end + + def extract_metadata(message) + if message.is_a?(Hash) && message.key?("_rails") + new(message["_rails"]["exp"], message["_rails"]["pur"]) + end + end + end + + def match?(purpose) + @purpose == purpose.to_s + end + + def fresh? + @expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at) + end + end + end +end diff --git a/activesupport/lib/active_support/testing/time_helpers.rb b/activesupport/lib/active_support/testing/time_helpers.rb index b529592910..fa5f46736c 100644 --- a/activesupport/lib/active_support/testing/time_helpers.rb +++ b/activesupport/lib/active_support/testing/time_helpers.rb @@ -51,8 +51,14 @@ module ActiveSupport # Contains helpers that help you test passage of time. module TimeHelpers + def after_teardown + travel_back + super + end + # Changes current time to the time in the future or in the past by a given time difference by - # stubbing +Time.now+, +Date.today+, and +DateTime.now+. + # stubbing +Time.now+, +Date.today+, and +DateTime.now+. The stubs are automatically removed + # at the end of the test. # # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 # travel 1.day @@ -74,6 +80,7 @@ module ActiveSupport # Changes current time to the given time by stubbing +Time.now+, # +Date.today+, and +DateTime.now+ to return the time or date passed into this method. + # The stubs are automatically removed at the end of the test. # # Time.current # => Sat, 09 Nov 2013 15:34:49 EST -05:00 # travel_to Time.zone.local(2004, 11, 24, 01, 04, 44) diff --git a/activesupport/test/message_encryptor_test.rb b/activesupport/test/message_encryptor_test.rb index 832841597f..1fbe655642 100644 --- a/activesupport/test/message_encryptor_test.rb +++ b/activesupport/test/message_encryptor_test.rb @@ -4,6 +4,7 @@ require "abstract_unit" require "openssl" require "active_support/time" require "active_support/json" +require_relative "metadata/shared_metadata_tests" class MessageEncryptorTest < ActiveSupport::TestCase class JSONSerializer @@ -106,8 +107,15 @@ class MessageEncryptorTest < ActiveSupport::TestCase assert_aead_not_decrypted(encryptor, [text, iv, auth_tag[0..-2]] * "--") end - private + def test_backwards_compatibility_decrypt_previously_encrypted_messages_without_metadata + secret = "\xB7\xF0\xBCW\xB1\x18`\xAB\xF0\x81\x10\xA4$\xF44\xEC\xA1\xDC\xC1\xDDD\xAF\xA9\xB8\x14\xCD\x18\x9A\x99 \x80)" + encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm") + encrypted_message = "9cVnFs2O3lL9SPvIJuxBOLS51nDiBMw=--YNI5HAfHEmZ7VDpl--ddFJ6tXA0iH+XGcCgMINYQ==" + + assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message) + end + private def assert_aead_not_decrypted(encryptor, value) assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do encryptor.decrypt_and_verify(value) @@ -132,3 +140,37 @@ class MessageEncryptorTest < ActiveSupport::TestCase ::Base64.strict_encode64(bits) end end + +class MessageEncryptorMetadataTest < ActiveSupport::TestCase + include SharedMessageMetadataTests + + setup do + @secret = SecureRandom.random_bytes(32) + @encryptor = ActiveSupport::MessageEncryptor.new(@secret, encryptor_options) + end + + private + def generate(message, **options) + @encryptor.encrypt_and_sign(message, options) + end + + def parse(data, **options) + @encryptor.decrypt_and_verify(data, options) + end + + def encryptor_options; end +end + +class MessageEncryptorMetadataMarshalTest < MessageEncryptorMetadataTest + private + def encryptor_options + { serializer: Marshal } + end +end + +class MessageEncryptorMetadataJSONTest < MessageEncryptorMetadataTest + private + def encryptor_options + { serializer: MessageEncryptorTest::JSONSerializer.new } + end +end diff --git a/activesupport/test/message_verifier_test.rb b/activesupport/test/message_verifier_test.rb index ba886f9fb4..f626ab745f 100644 --- a/activesupport/test/message_verifier_test.rb +++ b/activesupport/test/message_verifier_test.rb @@ -4,6 +4,7 @@ require "abstract_unit" require "openssl" require "active_support/time" require "active_support/json" +require_relative "metadata/shared_metadata_tests" class MessageVerifierTest < ActiveSupport::TestCase class JSONSerializer @@ -18,7 +19,7 @@ class MessageVerifierTest < ActiveSupport::TestCase def setup @verifier = ActiveSupport::MessageVerifier.new("Hey, I'm a secret!") - @data = { some: "data", now: Time.local(2010) } + @data = { some: "data", now: Time.utc(2010) } end def test_valid_message @@ -84,4 +85,59 @@ class MessageVerifierTest < ActiveSupport::TestCase end assert_equal "Secret should not be nil.", exception.message end + + def test_backward_compatibility_messages_signed_without_metadata + signed_message = "BAh7BzoJc29tZUkiCWRhdGEGOgZFVDoIbm93SXU6CVRpbWUNIIAbgAAAAAAHOgtvZmZzZXRpADoJem9uZUkiCFVUQwY7BkY=--d03c52c91dfe4ccc5159417c660461bcce005e96" + assert_equal @data, @verifier.verify(signed_message) + end +end + +class MessageVerifierMetadataTest < ActiveSupport::TestCase + include SharedMessageMetadataTests + + setup do + @verifier = ActiveSupport::MessageVerifier.new("Hey, I'm a secret!", verifier_options) + end + + def test_verify_raises_when_purpose_differs + assert_raise(ActiveSupport::MessageVerifier::InvalidSignature) do + @verifier.verify(@verifier.generate(@message, purpose: "payment"), purpose: "shipping") + end + end + + def test_verify_raises_when_expired + signed_message = @verifier.generate(@message, expires_in: 1.month) + + travel 2.months + assert_raise(ActiveSupport::MessageVerifier::InvalidSignature) do + @verifier.verify(signed_message) + end + end + + private + def generate(message, **options) + @verifier.generate(message, options) + end + + def parse(message, **options) + @verifier.verified(message, options) + end + + def verifier_options + Hash.new + end +end + +class MessageVerifierMetadataMarshalTest < MessageVerifierMetadataTest + private + def verifier_options + { serializer: Marshal } + end +end + +class MessageVerifierMetadataJSONTest < MessageVerifierMetadataTest + private + def verifier_options + { serializer: MessageVerifierTest::JSONSerializer.new } + end end diff --git a/activesupport/test/metadata/shared_metadata_tests.rb b/activesupport/test/metadata/shared_metadata_tests.rb new file mode 100644 index 0000000000..7d88e255c7 --- /dev/null +++ b/activesupport/test/metadata/shared_metadata_tests.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SharedMessageMetadataTests + def setup + @message = { "credit_card_no" => "5012-6784-9087-5678", "card_holder" => { "name" => "Donald" } } + + super + end + + def teardown + travel_back + + super + end + + def test_encryption_and_decryption_with_same_purpose + assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: "checkout") + assert_equal @message, parse(generate(@message)) + + string_message = "address: #23, main street" + assert_equal string_message, parse(generate(string_message, purpose: "shipping"), purpose: "shipping") + + array_message = ["credit_card_no: 5012-6748-9087-5678", { "card_holder" => "Donald", "issued_on" => Time.local(2017) }, 12345] + assert_equal array_message, parse(generate(array_message, purpose: "registration"), purpose: "registration") + end + + def test_encryption_and_decryption_with_different_purposes_returns_nil + assert_nil parse(generate(@message, purpose: "payment"), purpose: "sign up") + assert_nil parse(generate(@message, purpose: "payment")) + assert_nil parse(generate(@message), purpose: "sign up") + assert_nil parse(generate(@message), purpose: "") + end + + def test_purpose_using_symbols + assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: :checkout) + assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: "checkout") + assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: :checkout) + end + + def test_passing_expires_at_sets_expiration_date + encrypted_message = generate(@message, expires_at: 1.hour.from_now) + + travel 59.minutes + assert_equal @message, parse(encrypted_message) + + travel 2.minutes + assert_nil parse(encrypted_message) + end + + def test_set_relative_expiration_date_by_passing_expires_in + encrypted_message = generate(@message, expires_in: 2.hours) + + travel 1.hour + assert_equal @message, parse(encrypted_message) + + travel 1.hour + 1.second + assert_nil parse(encrypted_message) + end + + def test_passing_expires_in_less_than_a_second_is_not_expired + freeze_time do + encrypted_message = generate(@message, expires_in: 1.second) + + travel 0.5.seconds + assert_equal @message, parse(encrypted_message) + + travel 1.second + assert_nil parse(encrypted_message) + end + end + + def test_favor_expires_at_over_expires_in + payment_related_message = generate(@message, purpose: "payment", expires_at: 2.year.from_now, expires_in: 1.second) + + travel 1.year + assert_equal @message, parse(payment_related_message, purpose: :payment) + + travel 1.year + 1.day + assert_nil parse(payment_related_message, purpose: "payment") + end + + def test_skip_expires_at_and_expires_in_to_disable_expiration_check + payment_related_message = generate(@message, purpose: "payment") + + travel 100.years + assert_equal @message, parse(payment_related_message, purpose: "payment") + end +end diff --git a/activesupport/test/time_zone_test.rb b/activesupport/test/time_zone_test.rb index 04f1a53dd9..acb0ecd226 100644 --- a/activesupport/test/time_zone_test.rb +++ b/activesupport/test/time_zone_test.rb @@ -103,7 +103,6 @@ class TimeZoneTest < ActiveSupport::TestCase assert_equal Date.new(2000, 1, 1), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].today travel_to(Time.utc(2000, 1, 2, 5)) # midnight Jan 2 EST assert_equal Date.new(2000, 1, 2), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].today - travel_back end def test_tomorrow @@ -115,7 +114,6 @@ class TimeZoneTest < ActiveSupport::TestCase assert_equal Date.new(2000, 1, 2), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].tomorrow travel_to(Time.utc(2000, 1, 2, 5)) # midnight Jan 2 EST assert_equal Date.new(2000, 1, 3), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].tomorrow - travel_back end def test_yesterday @@ -127,7 +125,6 @@ class TimeZoneTest < ActiveSupport::TestCase assert_equal Date.new(1999, 12, 31), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].yesterday travel_to(Time.utc(2000, 1, 2, 5)) # midnight Jan 2 EST assert_equal Date.new(2000, 1, 1), ActiveSupport::TimeZone["Eastern Time (US & Canada)"].yesterday - travel_back end def test_travel_to_a_date |