Skip to content

Commit 38ea47d

Browse files
jcmfernandesioquatix
authored andcommitted
Allow the v2 encryptor to serialize messages with Marshal (#44)
Marshal and JSON serialization are unavoidably different. A key difference being that JSON serializes ruby symbols as strings, while Marshal preserves them as symbols. * Support UTF-8 data when using the JSON serializer * Replace all instance of 'BINARY' with `Encoding::BINARY`
1 parent 43f2e3a commit 38ea47d

2 files changed

Lines changed: 142 additions & 113 deletions

File tree

lib/rack/session/encryptor.rb

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,13 @@ class V2
223223
#
224224
# Considerations about V2:
225225
#
226-
# 1) It serializes messages in JSON, period.
227-
#
228-
# 2) It uses non URL-safe Base64 encoding as it's faster than its
226+
# 1) It uses non URL-safe Base64 encoding as it's faster than its
229227
# URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
230-
# roughly equivalent to do Base64.strict_encode64(data).tr("-_",
231-
# "+/") - and cookie values don't need to be URL-safe.
228+
# roughly equivalent to
229+
#
230+
# Base64.strict_encode64(data).tr("-_", "+/")
231+
#
232+
# - and cookie values don't need to be URL-safe.
232233
def initialize(secret, opts = {})
233234
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)
234235

@@ -246,9 +247,8 @@ def initialize(secret, opts = {})
246247
end
247248

248249
@options = {
249-
pad_size: 32, purpose: nil
250+
serialize_json: false, pad_size: 32, purpose: nil
250251
}.update(opts)
251-
@options[:serialize_json] = true # Enforce JSON serialization
252252

253253
@cipher_secret = secret.dup.force_encoding(Encoding::BINARY).slice!(0, 32)
254254
@cipher_secret.freeze

test/spec_session_encryptor.rb

Lines changed: 135 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -10,102 +10,118 @@
1010
require 'json'
1111
require 'securerandom'
1212

13-
module EncryptorTests
14-
def self.included(_base)
15-
describe 'encryptor' do
16-
it 'initialize does not destroy key string' do
17-
encryptor_class.new(@secret)
13+
def all_versions_tests(opts = {})
14+
Module.new do
15+
define_method(:default_opts) do
16+
opts
17+
end
1818

19-
@secret.size.must_equal 64
20-
end
19+
def self.included(_base)
20+
describe 'encryptor' do
21+
it 'initialize does not destroy key string' do
22+
new_encryptor(@secret)
2123

22-
it 'initialize raises ArgumentError on invalid key' do
23-
-> { encryptor_class.new ['foo'] }.must_raise ArgumentError
24-
end
24+
@secret.size.must_equal 64
25+
end
2526

26-
it 'initialize raises ArgumentError on short key' do
27-
-> { encryptor_class.new 'key' }.must_raise ArgumentError
28-
end
27+
it 'initialize raises ArgumentError on invalid key' do
28+
-> { new_encryptor ['foo'] }.must_raise ArgumentError
29+
end
2930

30-
it 'decrypts an encrypted message' do
31-
encryptor = encryptor_class.new(@secret)
31+
it 'initialize raises ArgumentError on short key' do
32+
-> { new_encryptor 'key' }.must_raise ArgumentError
33+
end
3234

33-
message = encryptor.encrypt({ 'foo' => 'bar' })
35+
it 'decrypts an encrypted message' do
36+
encryptor = new_encryptor(@secret)
3437

35-
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
36-
end
38+
message = encryptor.encrypt({ 'foo' => 'bar' })
3739

38-
it 'decrypt raises InvalidSignature for tampered messages' do
39-
encryptor = encryptor_class.new(@secret)
40+
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
41+
end
4042

41-
message = encryptor.encrypt({ 'foo' => 'bar' })
43+
it 'decrypt raises InvalidSignature for tampered messages' do
44+
encryptor = new_encryptor(@secret)
4245

43-
decoded_message = Base64.urlsafe_decode64(message)
44-
tampered_message = Base64.urlsafe_encode64(decoded_message.tap do |m|
45-
m[m.size - 1] = (m[m.size - 1].unpack1('C') ^ 1).chr
46-
end)
46+
message = encryptor.encrypt({ 'foo' => 'bar' })
4747

48-
lambda {
49-
encryptor.decrypt(tampered_message)
50-
}.must_raise Rack::Session::Encryptor::InvalidSignature
51-
end
48+
decoded_message = Base64.urlsafe_decode64(message)
49+
tampered_message = Base64.urlsafe_encode64(decoded_message.tap do |m|
50+
m[m.size - 1] = (m[m.size - 1].unpack1('C') ^ 1).chr
51+
end)
5252

53-
it 'decrypts an encrypted message with purpose' do
54-
encryptor = encryptor_class.new(@secret, purpose: 'testing')
53+
lambda {
54+
encryptor.decrypt(tampered_message)
55+
}.must_raise Rack::Session::Encryptor::InvalidSignature
56+
end
5557

56-
message = encryptor.encrypt({ 'foo' => 'bar' })
58+
it 'decrypts an encrypted message with purpose' do
59+
encryptor = new_encryptor(@secret, purpose: 'testing')
5760

58-
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
59-
end
61+
message = encryptor.encrypt({ 'foo' => 'bar' })
6062

61-
# The V1 encryptor defaults to the Marshal serializer, while the V2
62-
# encryptor always uses the JSON serializer. This means that we are
63-
# indirectly covering both serializers.
64-
it 'decrypts an encrypted message with UTF-8 data' do
65-
encryptor = encryptor_class.new(@secret)
63+
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
64+
end
6665

67-
encrypted_message = encryptor.encrypt({ 'foo' => 'bar 😀' })
68-
decrypted_message = encryptor.decrypt(encrypted_message)
66+
it 'decrypts an encrypted message with UTF-8 data' do
67+
encryptor = new_encryptor(@secret)
6968

70-
decrypted_message.must_equal({ 'foo' => 'bar 😀' })
71-
end
69+
encrypted_message = encryptor.encrypt({ 'foo' => 'bar 😀' })
70+
decrypted_message = encryptor.decrypt(encrypted_message)
7271

73-
it 'decrypts raises InvalidSignature without purpose' do
74-
encryptor = encryptor_class.new(@secret, purpose: 'testing')
75-
other_encryptor = encryptor_class.new(@secret)
72+
decrypted_message.must_equal({ 'foo' => 'bar 😀' })
73+
end
7674

77-
message = other_encryptor.encrypt({ 'foo' => 'bar' })
75+
it 'decrypts raises InvalidSignature without purpose' do
76+
encryptor = new_encryptor(@secret, purpose: 'testing')
77+
other_encryptor = new_encryptor(@secret)
7878

79-
-> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature
80-
end
79+
message = other_encryptor.encrypt({ 'foo' => 'bar' })
8180

82-
it 'decrypts raises InvalidSignature with different purpose' do
83-
encryptor = encryptor_class.new(@secret, purpose: 'testing')
84-
other_encryptor = encryptor_class.new(@secret, purpose: 'other')
81+
-> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature
82+
end
8583

86-
message = other_encryptor.encrypt({ 'foo' => 'bar' })
84+
it 'decrypts raises InvalidSignature with different purpose' do
85+
encryptor = new_encryptor(@secret, purpose: 'testing')
86+
other_encryptor = new_encryptor(@secret, purpose: 'other')
8787

88-
-> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature
89-
end
88+
message = other_encryptor.encrypt({ 'foo' => 'bar' })
9089

91-
it 'initialize raises ArgumentError on invalid pad_size' do
92-
-> { encryptor_class.new @secret, pad_size: :bar }.must_raise ArgumentError
93-
end
90+
-> { encryptor.decrypt(message) }.must_raise Rack::Session::Encryptor::InvalidSignature
91+
end
9492

95-
it 'initialize raises ArgumentError on to short pad_size' do
96-
-> { encryptor_class.new @secret, pad_size: 1 }.must_raise ArgumentError
97-
end
93+
it 'initialize raises ArgumentError on invalid pad_size' do
94+
-> { new_encryptor @secret, pad_size: :bar }.must_raise ArgumentError
95+
end
9896

99-
it 'initialize raises ArgumentError on to long pad_size' do
100-
-> { encryptor_class.new @secret, pad_size: 8023 }.must_raise ArgumentError
101-
end
97+
it 'initialize raises ArgumentError on to short pad_size' do
98+
-> { new_encryptor @secret, pad_size: 1 }.must_raise ArgumentError
99+
end
100+
101+
it 'initialize raises ArgumentError on to long pad_size' do
102+
-> { new_encryptor @secret, pad_size: 8023 }.must_raise ArgumentError
103+
end
104+
105+
it 'decrypts an encrypted message without pad_size' do
106+
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil)
102107

103-
it 'decrypts an encrypted message without pad_size' do
104-
encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: nil)
108+
message = encryptor.encrypt({ 'foo' => 'bar' })
105109

106-
message = encryptor.encrypt({ 'foo' => 'bar' })
110+
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
111+
end
107112

108-
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
113+
it 'encryptor with pad_size increases message size' do
114+
no_pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil)
115+
pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 64)
116+
117+
message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt(''))
118+
message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt(''))
119+
message_size_diff = message_with.bytesize - message_without.bytesize
120+
121+
message_with.bytesize.must_be :>, message_without.bytesize
122+
serializer = default_opts[:serialize_json] ? JSON : Marshal
123+
message_size_diff.must_equal 64 - serializer.dump('').bytesize - 2
124+
end
109125
end
110126
end
111127
end
@@ -116,15 +132,24 @@ def setup
116132
@secret = SecureRandom.random_bytes(64)
117133
end
118134

135+
def new_encryptor(secret, opts = {})
136+
if respond_to?(:default_opts)
137+
encryptor_class.new(secret, default_opts.merge(opts))
138+
else
139+
encryptor_class.new(secret, opts)
140+
end
141+
end
142+
119143
describe 'V1' do
120144
def encryptor_class
121145
Rack::Session::Encryptor::V1
122146
end
123147

124-
include EncryptorTests
148+
include all_versions_tests(serialize_json: false)
149+
include all_versions_tests(serialize_json: true)
125150

126151
it 'encryptor with pad_size has message payload size to multiple of pad_size' do
127-
encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 24)
152+
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
128153
message = encryptor.encrypt({ 'foo' => 'bar' * 4 })
129154

130155
decoded_message = Base64.urlsafe_decode64(message)
@@ -136,24 +161,12 @@ def encryptor_class
136161
(encrypted_payload.bytesize % 24).must_equal 0
137162
end
138163

139-
it 'encryptor with pad_size increases message size' do
140-
no_pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: nil)
141-
pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 64)
142-
143-
message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt(''))
144-
message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt(''))
145-
message_size_diff = message_with.bytesize - message_without.bytesize
146-
147-
message_with.bytesize.must_be :>, message_without.bytesize
148-
message_size_diff.must_equal 64 - Marshal.dump('').bytesize - 2
149-
end
150-
151164
# This test checks the one-time message key IS NOT used as the cipher key.
152165
# Doing so would remove the confidentiality assurances as the key is
153166
# essentially included in plaintext then.
154167
it 'uses a secret cipher key for encryption and decryption' do
155168
cipher = OpenSSL::Cipher.new('aes-256-ctr')
156-
encryptor = encryptor_class.new(@secret)
169+
encryptor = new_encryptor(@secret)
157170

158171
message = encryptor.encrypt({ 'foo' => 'bar' })
159172
raw_message = Base64.urlsafe_decode64(message)
@@ -178,7 +191,7 @@ def encryptor_class
178191
end
179192

180193
it 'it calls set_cipher_key with the correct key' do
181-
encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 24)
194+
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
182195
message = encryptor.encrypt({ 'foo' => 'bar' })
183196

184197
message_key = Base64.urlsafe_decode64(message).slice(1, 32)
@@ -194,17 +207,36 @@ def encryptor_class
194207
encryptor.decrypt message
195208
end
196209
end
210+
211+
it 'preserves symbols when payloads are not encoded into JSON' do
212+
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false)
213+
214+
encrypted_message = encryptor.encrypt({ foo: 'bar' })
215+
decrypted_message = encryptor.decrypt(encrypted_message)
216+
217+
decrypted_message.must_equal({ foo: 'bar' })
218+
end
219+
220+
it 'does not preserves symbols when payloads are encoded into JSON' do
221+
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true)
222+
223+
encrypted_message = encryptor.encrypt({ foo: 'bar' })
224+
decrypted_message = encryptor.decrypt(encrypted_message)
225+
226+
decrypted_message.must_equal({ 'foo' => 'bar' })
227+
end
197228
end
198229

199230
describe 'V2' do
200231
def encryptor_class
201232
Rack::Session::Encryptor::V2
202233
end
203234

204-
include EncryptorTests
235+
include all_versions_tests(serialize_json: false)
236+
include all_versions_tests(serialize_json: true)
205237

206238
it 'encryptor with pad_size has message payload size to multiple of pad_size' do
207-
encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 24)
239+
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
208240
message = encryptor.encrypt({ 'foo' => 'bar' * 4 })
209241

210242
decoded_message = Base64.strict_decode64(message)
@@ -216,20 +248,8 @@ def encryptor_class
216248
(encrypted_payload.bytesize % 24).must_equal 0
217249
end
218250

219-
it 'encryptor with pad_size increases message size' do
220-
no_pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: nil)
221-
pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 64)
222-
223-
message_without = Base64.strict_decode64(no_pad_encryptor.encrypt(''))
224-
message_with = Base64.strict_decode64(pad_encryptor.encrypt(''))
225-
message_size_diff = message_with.bytesize - message_without.bytesize
226-
227-
message_with.bytesize.must_be :>, message_without.bytesize
228-
message_size_diff.must_equal 64 - JSON.dump('').bytesize - 2
229-
end
230-
231251
it 'raises InvalidMessage on version mismatch' do
232-
encryptor = encryptor_class.new(@secret, purpose: 'testing')
252+
encryptor = new_encryptor(@secret, purpose: 'testing')
233253
message = encryptor.encrypt({ 'foo' => 'bar' })
234254

235255
decoded_message = Base64.strict_decode64(message)
@@ -244,7 +264,7 @@ def encryptor_class
244264
# essentially included in plaintext then.
245265
it 'uses a secret cipher key for encryption and decryption' do
246266
cipher = OpenSSL::Cipher.new('aes-256-gcm')
247-
encryptor = encryptor_class.new(@secret)
267+
encryptor = new_encryptor(@secret)
248268

249269
message = encryptor.encrypt({ 'foo' => 'bar' })
250270
raw_message = Base64.strict_decode64(message)
@@ -264,7 +284,7 @@ def encryptor_class
264284
end
265285

266286
it 'it calls set_cipher_key with the correct key' do
267-
encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 24)
287+
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 24)
268288
message = encryptor.encrypt({ 'foo' => 'bar' })
269289

270290
message_key = Base64.strict_decode64(message).slice(1, 32)
@@ -281,13 +301,22 @@ def encryptor_class
281301
end
282302
end
283303

284-
it 'ignores serialize_json' do
285-
encryptor_no_json = encryptor_class.new(@secret, purpose: 'testing', serialize_json: false)
286-
encryptor = encryptor_class.new(@secret, purpose: 'testing', serialize_json: true)
304+
it 'preserves symbols when payloads are not encoded into JSON' do
305+
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false)
287306

288-
message = encryptor_no_json.encrypt({ 'foo' => 'bar' })
307+
encrypted_message = encryptor.encrypt({ foo: 'bar' })
308+
decrypted_message = encryptor.decrypt(encrypted_message)
289309

290-
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
310+
decrypted_message.must_equal({ foo: 'bar' })
311+
end
312+
313+
it 'does not preserves symbols when payloads are encoded into JSON' do
314+
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true)
315+
316+
encrypted_message = encryptor.encrypt({ foo: 'bar' })
317+
decrypted_message = encryptor.decrypt(encrypted_message)
318+
319+
decrypted_message.must_equal({ 'foo' => 'bar' })
291320
end
292321
end
293322

0 commit comments

Comments
 (0)