Skip to content

Commit 1f7f872

Browse files
authored
Merge pull request rails#29599 from assain/add_meta_data_to_message_encryptor
Add purpose and expiry to messages encrypted using Message Encryptor
2 parents d1281cd + 3b506ee commit 1f7f872

File tree

4 files changed

+192
-6
lines changed

4 files changed

+192
-6
lines changed

activesupport/lib/active_support/message_encryptor.rb

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "base64"
55
require_relative "core_ext/array/extract_options"
66
require_relative "message_verifier"
7+
require_relative "messages/metadata"
78

89
module ActiveSupport
910
# MessageEncryptor is a simple way to encrypt values which get stored
@@ -87,14 +88,15 @@ def initialize(secret, *signature_key_or_options)
8788

8889
# Encrypt and sign a message. We need to sign the message in order to avoid
8990
# padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
90-
def encrypt_and_sign(value)
91-
verifier.generate(_encrypt(value))
91+
def encrypt_and_sign(value, expires_at: nil, expires_in: nil, purpose: nil)
92+
data = Messages::Metadata.wrap(value, expires_at: expires_at, expires_in: expires_in, purpose: purpose)
93+
verifier.generate(_encrypt(data))
9294
end
9395

9496
# Decrypt and verify a message. We need to verify the message in order to
9597
# avoid padding attacks. Reference: http://www.limited-entropy.com/padding-oracle-attacks.
96-
def decrypt_and_verify(value)
97-
_decrypt(verifier.verify(value))
98+
def decrypt_and_verify(data, purpose: nil)
99+
Messages::Metadata.verify(_decrypt(verifier.verify(data)), purpose)
98100
end
99101

100102
# Given a cipher, returns the key length of the cipher to help generate the key of desired size
@@ -103,7 +105,6 @@ def self.key_len(cipher = default_cipher)
103105
end
104106

105107
private
106-
107108
def _encrypt(value)
108109
cipher = new_cipher
109110
cipher.encrypt
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# frozen_string_literal: true
2+
require "time"
3+
4+
module ActiveSupport
5+
module Messages #:nodoc:
6+
class Metadata #:nodoc:
7+
def initialize(expires_at, purpose)
8+
@expires_at, @purpose = expires_at, purpose
9+
end
10+
11+
class << self
12+
def wrap(message, expires_at: nil, expires_in: nil, purpose: nil)
13+
if expires_at || expires_in || purpose
14+
{ "value" => message, "_rails" => { "exp" => pick_expiry(expires_at, expires_in), "pur" => purpose.to_s } }
15+
else
16+
message
17+
end
18+
end
19+
20+
def verify(message, purpose)
21+
metadata = extract_metadata(message)
22+
23+
if metadata.nil?
24+
message if purpose.nil?
25+
elsif metadata.match?(purpose.to_s) && metadata.fresh?
26+
message["value"]
27+
end
28+
end
29+
30+
private
31+
def pick_expiry(expires_at, expires_in)
32+
if expires_at
33+
expires_at.utc.iso8601(3)
34+
elsif expires_in
35+
expires_in.from_now.utc.iso8601(3)
36+
end
37+
end
38+
39+
def extract_metadata(message)
40+
if message.is_a?(Hash) && message.key?("_rails")
41+
new(message["_rails"]["exp"], message["_rails"]["pur"])
42+
end
43+
end
44+
end
45+
46+
def match?(purpose)
47+
@purpose == purpose
48+
end
49+
50+
def fresh?
51+
@expires_at.nil? || Time.now.utc < Time.iso8601(@expires_at)
52+
end
53+
end
54+
end
55+
end

activesupport/test/message_encryptor_test.rb

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require "openssl"
55
require "active_support/time"
66
require "active_support/json"
7+
require_relative "metadata/shared_metadata_tests"
78

89
class MessageEncryptorTest < ActiveSupport::TestCase
910
class JSONSerializer
@@ -106,8 +107,15 @@ def test_messing_with_aead_values_causes_failures
106107
assert_aead_not_decrypted(encryptor, [text, iv, auth_tag[0..-2]] * "--")
107108
end
108109

109-
private
110+
def test_backwards_compatibility_decrypt_previously_encrypted_messages_without_metadata
111+
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)"
112+
encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: "aes-256-gcm")
113+
encrypted_message = "9cVnFs2O3lL9SPvIJuxBOLS51nDiBMw=--YNI5HAfHEmZ7VDpl--ddFJ6tXA0iH+XGcCgMINYQ=="
114+
115+
assert_equal "Ruby on Rails", encryptor.decrypt_and_verify(encrypted_message)
116+
end
110117

118+
private
111119
def assert_aead_not_decrypted(encryptor, value)
112120
assert_raise(ActiveSupport::MessageEncryptor::InvalidMessage) do
113121
encryptor.decrypt_and_verify(value)
@@ -132,3 +140,37 @@ def munge(base64_string)
132140
::Base64.strict_encode64(bits)
133141
end
134142
end
143+
144+
class MessageEncryptorMetadataTest < ActiveSupport::TestCase
145+
include SharedMessageMetadataTests
146+
147+
setup do
148+
@secret = SecureRandom.random_bytes(32)
149+
@encryptor = ActiveSupport::MessageEncryptor.new(@secret, encryptor_options)
150+
end
151+
152+
private
153+
def generate(message, **options)
154+
@encryptor.encrypt_and_sign(message, options)
155+
end
156+
157+
def parse(data, **options)
158+
@encryptor.decrypt_and_verify(data, options)
159+
end
160+
161+
def encryptor_options; end
162+
end
163+
164+
class MessageEncryptorMetadataMarshalTest < MessageEncryptorMetadataTest
165+
private
166+
def encryptor_options
167+
{ serializer: Marshal }
168+
end
169+
end
170+
171+
class MessageEncryptorMetadataJSONTest < MessageEncryptorMetadataTest
172+
private
173+
def encryptor_options
174+
{ serializer: MessageEncryptorTest::JSONSerializer.new }
175+
end
176+
end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# frozen_string_literal: true
2+
3+
module SharedMessageMetadataTests
4+
def setup
5+
@message = { "credit_card_no" => "5012-6784-9087-5678", "card_holder" => { "name" => "Donald" } }
6+
7+
super
8+
end
9+
10+
def teardown
11+
travel_back
12+
13+
super
14+
end
15+
16+
def test_encryption_and_decryption_with_same_purpose
17+
assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: "checkout")
18+
assert_equal @message, parse(generate(@message))
19+
20+
string_message = "address: #23, main street"
21+
assert_equal string_message, parse(generate(string_message, purpose: "shipping"), purpose: "shipping")
22+
23+
array_message = ["credit_card_no: 5012-6748-9087-5678", { "card_holder" => "Donald", "issued_on" => Time.local(2017) }, 12345]
24+
assert_equal array_message, parse(generate(array_message, purpose: "registration"), purpose: "registration")
25+
end
26+
27+
def test_encryption_and_decryption_with_different_purposes_returns_nil
28+
assert_nil parse(generate(@message, purpose: "payment"), purpose: "sign up")
29+
assert_nil parse(generate(@message, purpose: "payment"))
30+
assert_nil parse(generate(@message), purpose: "sign up")
31+
assert_nil parse(generate(@message), purpose: "")
32+
end
33+
34+
def test_purpose_using_symbols
35+
assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: :checkout)
36+
assert_equal @message, parse(generate(@message, purpose: :checkout), purpose: "checkout")
37+
assert_equal @message, parse(generate(@message, purpose: "checkout"), purpose: :checkout)
38+
end
39+
40+
def test_passing_expires_at_sets_expiration_date
41+
encrypted_message = generate(@message, expires_at: 1.hour.from_now)
42+
43+
travel 59.minutes
44+
assert_equal @message, parse(encrypted_message)
45+
46+
travel 2.minutes
47+
assert_nil parse(encrypted_message)
48+
end
49+
50+
def test_set_relative_expiration_date_by_passing_expires_in
51+
encrypted_message = generate(@message, expires_in: 2.hours)
52+
53+
travel 1.hour
54+
assert_equal @message, parse(encrypted_message)
55+
56+
travel 1.hour + 1.second
57+
assert_nil parse(encrypted_message)
58+
end
59+
60+
def test_passing_expires_in_less_than_a_second_is_not_expired
61+
freeze_time do
62+
encrypted_message = generate(@message, expires_in: 1.second)
63+
64+
travel 0.5.seconds
65+
assert_equal @message, parse(encrypted_message)
66+
67+
travel 1.second
68+
assert_nil parse(encrypted_message)
69+
end
70+
end
71+
72+
def test_favor_expires_at_over_expires_in
73+
payment_related_message = generate(@message, purpose: "payment", expires_at: 2.year.from_now, expires_in: 1.second)
74+
75+
travel 1.year
76+
assert_equal @message, parse(payment_related_message, purpose: :payment)
77+
78+
travel 1.year + 1.day
79+
assert_nil parse(payment_related_message, purpose: "payment")
80+
end
81+
82+
def test_skip_expires_at_and_expires_in_to_disable_expiration_check
83+
payment_related_message = generate(@message, purpose: "payment")
84+
85+
travel 100.years
86+
assert_equal @message, parse(payment_related_message, purpose: "payment")
87+
end
88+
end

0 commit comments

Comments
 (0)