aboutsummaryrefslogtreecommitdiffstats
path: root/activesupport
diff options
context:
space:
mode:
Diffstat (limited to 'activesupport')
-rw-r--r--activesupport/CHANGELOG.md22
-rw-r--r--activesupport/lib/active_support/message_encryptor.rb43
-rw-r--r--activesupport/lib/active_support/message_verifier.rb53
-rw-r--r--activesupport/lib/active_support/messages/metadata.rb55
-rw-r--r--activesupport/lib/active_support/testing/time_helpers.rb9
-rw-r--r--activesupport/test/message_encryptor_test.rb44
-rw-r--r--activesupport/test/message_verifier_test.rb58
-rw-r--r--activesupport/test/metadata/shared_metadata_tests.rb88
-rw-r--r--activesupport/test/time_zone_test.rb3
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