mirror of
https://github.com/rails/rails.git
synced 2022-11-09 12:12:34 -05:00
Add expires_at, expires_in, and purpose meta_data to messages.
This commit is contained in:
parent
d1281cdc2c
commit
3b506ee0d8
4 changed files with 192 additions and 6 deletions
|
@ -4,6 +4,7 @@ require "openssl"
|
||||||
require "base64"
|
require "base64"
|
||||||
require_relative "core_ext/array/extract_options"
|
require_relative "core_ext/array/extract_options"
|
||||||
require_relative "message_verifier"
|
require_relative "message_verifier"
|
||||||
|
require_relative "messages/metadata"
|
||||||
|
|
||||||
module ActiveSupport
|
module ActiveSupport
|
||||||
# MessageEncryptor is a simple way to encrypt values which get stored
|
# MessageEncryptor is a simple way to encrypt values which get stored
|
||||||
|
@ -87,14 +88,15 @@ module ActiveSupport
|
||||||
|
|
||||||
# Encrypt and sign a message. We need to sign the message in order to avoid
|
# 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.
|
# padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
|
||||||
def encrypt_and_sign(value)
|
def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil)
|
||||||
verifier.generate(_encrypt(value))
|
data = Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose)
|
||||||
|
verifier.generate(_encrypt(data))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Decrypt and verify a message. We need to verify the message in order to
|
# 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.
|
# avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
|
||||||
def decrypt_and_verify(value)
|
def decrypt_and_verify(data, purpose: nil)
|
||||||
_decrypt(verifier.verify(value))
|
Messages::Metadata.verify(_decrypt(verifier.verify(data)), purpose)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a cipher, returns the key length of the cipher to help generate the key of desired size
|
# Given a cipher, returns the key length of the cipher to help generate the key of desired size
|
||||||
|
@ -103,7 +105,6 @@ module ActiveSupport
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def _encrypt(value)
|
def _encrypt(value)
|
||||||
cipher = new_cipher
|
cipher = new_cipher
|
||||||
cipher.encrypt
|
cipher.encrypt
|
||||||
|
|
55
activesupport/lib/active_support/messages/metadata.rb
Normal file
55
activesupport/lib/active_support/messages/metadata.rb
Normal file
|
@ -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
|
||||||
|
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.to_s } }
|
||||||
|
else
|
||||||
|
message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def verify(message, purpose)
|
||||||
|
metadata = extract_metadata(message)
|
||||||
|
|
||||||
|
if metadata.nil?
|
||||||
|
message if purpose.nil?
|
||||||
|
elsif metadata.match?(purpose.to_s) && 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
|
||||||
|
expires_in.from_now.utc.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
|
||||||
|
end
|
||||||
|
|
||||||
|
def fresh?
|
||||||
|
@expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -4,6 +4,7 @@ require "abstract_unit"
|
||||||
require "openssl"
|
require "openssl"
|
||||||
require "active_support/time"
|
require "active_support/time"
|
||||||
require "active_support/json"
|
require "active_support/json"
|
||||||
|
require_relative "metadata/shared_metadata_tests"
|
||||||
|
|
||||||
class MessageEncryptorTest < ActiveSupport::TestCase
|
class MessageEncryptorTest < ActiveSupport::TestCase
|
||||||
class JSONSerializer
|
class JSONSerializer
|
||||||
|
@ -106,8 +107,15 @@ class MessageEncryptorTest < ActiveSupport::TestCase
|
||||||
assert_aead_not_decrypted(encryptor, [text, iv, auth_tag[0..-2]] * "--")
|
assert_aead_not_decrypted(encryptor, [text, iv, auth_tag[0..-2]] * "--")
|
||||||
end
|
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)
|
def assert_aead_not_decrypted(encryptor, value)
|
||||||
assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do
|
assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do
|
||||||
encryptor.decrypt_and_verify(value)
|
encryptor.decrypt_and_verify(value)
|
||||||
|
@ -132,3 +140,37 @@ class MessageEncryptorTest < ActiveSupport::TestCase
|
||||||
::Base64.strict_encode64(bits)
|
::Base64.strict_encode64(bits)
|
||||||
end
|
end
|
||||||
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
|
||||||
|
|
88
activesupport/test/metadata/shared_metadata_tests.rb
Normal file
88
activesupport/test/metadata/shared_metadata_tests.rb
Normal file
|
@ -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
|
Loading…
Reference in a new issue