Skip to content

Commit

Permalink
Allow the v2 encryptor to serialize messages with Marshal (#44)
Browse files Browse the repository at this point in the history
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`
  • Loading branch information
jcmfernandes authored and ioquatix committed Jan 4, 2025
1 parent 0476a10 commit 6bf68bb
Show file tree
Hide file tree
Showing 2 changed files with 142 additions and 113 deletions.
14 changes: 7 additions & 7 deletions lib/rack/session/encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,13 @@ class V2
#
# Considerations about V2:
#
# 1) It serializes messages in JSON, period.
#
# 2) It uses non URL-safe Base64 encoding as it's faster than its
# 1) It uses non URL-safe Base64 encoding as it's faster than its
# URL-safe counterpart - as of Ruby 3.2, Base64.urlsafe_encode64 is
# roughly equivalent to do Base64.strict_encode64(data).tr("-_",
# "+/") - and cookie values don't need to be URL-safe.
# roughly equivalent to
#
# Base64.strict_encode64(data).tr("-_", "+/")
#
# - and cookie values don't need to be URL-safe.
def initialize(secret, opts = {})
raise ArgumentError, 'secret must be a String' unless secret.is_a?(String)

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

@options = {
pad_size: 32, purpose: nil
serialize_json: false, pad_size: 32, purpose: nil
}.update(opts)
@options[:serialize_json] = true # Enforce JSON serialization

@cipher_secret = secret.dup.force_encoding(Encoding::BINARY).slice!(0, 32)
@cipher_secret.freeze
Expand Down
241 changes: 135 additions & 106 deletions test/spec_session_encryptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,102 +10,118 @@
require 'json'
require 'securerandom'

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

@secret.size.must_equal 64
end
def self.included(_base)
describe 'encryptor' do
it 'initialize does not destroy key string' do
new_encryptor(@secret)

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

it 'initialize raises ArgumentError on short key' do
-> { encryptor_class.new 'key' }.must_raise ArgumentError
end
it 'initialize raises ArgumentError on invalid key' do
-> { new_encryptor ['foo'] }.must_raise ArgumentError
end

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

message = encryptor.encrypt({ 'foo' => 'bar' })
it 'decrypts an encrypted message' do
encryptor = new_encryptor(@secret)

encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
end
message = encryptor.encrypt({ 'foo' => 'bar' })

it 'decrypt raises InvalidSignature for tampered messages' do
encryptor = encryptor_class.new(@secret)
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
end

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

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

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

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

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

encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
end
message = encryptor.encrypt({ 'foo' => 'bar' })

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

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

decrypted_message.must_equal({ 'foo' => 'bar 😀' })
end
encrypted_message = encryptor.encrypt({ 'foo' => 'bar 😀' })
decrypted_message = encryptor.decrypt(encrypted_message)

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

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

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

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

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

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

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

it 'initialize raises ArgumentError on to short pad_size' do
-> { encryptor_class.new @secret, pad_size: 1 }.must_raise ArgumentError
end
it 'initialize raises ArgumentError on invalid pad_size' do
-> { new_encryptor @secret, pad_size: :bar }.must_raise ArgumentError
end

it 'initialize raises ArgumentError on to long pad_size' do
-> { encryptor_class.new @secret, pad_size: 8023 }.must_raise ArgumentError
end
it 'initialize raises ArgumentError on to short pad_size' do
-> { new_encryptor @secret, pad_size: 1 }.must_raise ArgumentError
end

it 'initialize raises ArgumentError on to long pad_size' do
-> { new_encryptor @secret, pad_size: 8023 }.must_raise ArgumentError
end

it 'decrypts an encrypted message without pad_size' do
encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil)

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

message = encryptor.encrypt({ 'foo' => 'bar' })
encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
end

encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
it 'encryptor with pad_size increases message size' do
no_pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: nil)
pad_encryptor = new_encryptor(@secret, purpose: 'testing', pad_size: 64)

message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt(''))
message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt(''))
message_size_diff = message_with.bytesize - message_without.bytesize

message_with.bytesize.must_be :>, message_without.bytesize
serializer = default_opts[:serialize_json] ? JSON : Marshal
message_size_diff.must_equal 64 - serializer.dump('').bytesize - 2
end
end
end
end
Expand All @@ -116,15 +132,24 @@ def setup
@secret = SecureRandom.random_bytes(64)
end

def new_encryptor(secret, opts = {})
if respond_to?(:default_opts)
encryptor_class.new(secret, default_opts.merge(opts))
else
encryptor_class.new(secret, opts)
end
end

describe 'V1' do
def encryptor_class
Rack::Session::Encryptor::V1
end

include EncryptorTests
include all_versions_tests(serialize_json: false)
include all_versions_tests(serialize_json: true)

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

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

it 'encryptor with pad_size increases message size' do
no_pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: nil)
pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 64)

message_without = Base64.urlsafe_decode64(no_pad_encryptor.encrypt(''))
message_with = Base64.urlsafe_decode64(pad_encryptor.encrypt(''))
message_size_diff = message_with.bytesize - message_without.bytesize

message_with.bytesize.must_be :>, message_without.bytesize
message_size_diff.must_equal 64 - Marshal.dump('').bytesize - 2
end

# This test checks the one-time message key IS NOT used as the cipher key.
# Doing so would remove the confidentiality assurances as the key is
# essentially included in plaintext then.
it 'uses a secret cipher key for encryption and decryption' do
cipher = OpenSSL::Cipher.new('aes-256-ctr')
encryptor = encryptor_class.new(@secret)
encryptor = new_encryptor(@secret)

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

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

message_key = Base64.urlsafe_decode64(message).slice(1, 32)
Expand All @@ -194,17 +207,36 @@ def encryptor_class
encryptor.decrypt message
end
end

it 'preserves symbols when payloads are not encoded into JSON' do
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false)

encrypted_message = encryptor.encrypt({ foo: 'bar' })
decrypted_message = encryptor.decrypt(encrypted_message)

decrypted_message.must_equal({ foo: 'bar' })
end

it 'does not preserves symbols when payloads are encoded into JSON' do
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true)

encrypted_message = encryptor.encrypt({ foo: 'bar' })
decrypted_message = encryptor.decrypt(encrypted_message)

decrypted_message.must_equal({ 'foo' => 'bar' })
end
end

describe 'V2' do
def encryptor_class
Rack::Session::Encryptor::V2
end

include EncryptorTests
include all_versions_tests(serialize_json: false)
include all_versions_tests(serialize_json: true)

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

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

it 'encryptor with pad_size increases message size' do
no_pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: nil)
pad_encryptor = encryptor_class.new(@secret, purpose: 'testing', pad_size: 64)

message_without = Base64.strict_decode64(no_pad_encryptor.encrypt(''))
message_with = Base64.strict_decode64(pad_encryptor.encrypt(''))
message_size_diff = message_with.bytesize - message_without.bytesize

message_with.bytesize.must_be :>, message_without.bytesize
message_size_diff.must_equal 64 - JSON.dump('').bytesize - 2
end

it 'raises InvalidMessage on version mismatch' do
encryptor = encryptor_class.new(@secret, purpose: 'testing')
encryptor = new_encryptor(@secret, purpose: 'testing')
message = encryptor.encrypt({ 'foo' => 'bar' })

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

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

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

message_key = Base64.strict_decode64(message).slice(1, 32)
Expand All @@ -281,13 +301,22 @@ def encryptor_class
end
end

it 'ignores serialize_json' do
encryptor_no_json = encryptor_class.new(@secret, purpose: 'testing', serialize_json: false)
encryptor = encryptor_class.new(@secret, purpose: 'testing', serialize_json: true)
it 'preserves symbols when payloads are not encoded into JSON' do
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: false)

message = encryptor_no_json.encrypt({ 'foo' => 'bar' })
encrypted_message = encryptor.encrypt({ foo: 'bar' })
decrypted_message = encryptor.decrypt(encrypted_message)

encryptor.decrypt(message).must_equal({ 'foo' => 'bar' })
decrypted_message.must_equal({ foo: 'bar' })
end

it 'does not preserves symbols when payloads are encoded into JSON' do
encryptor = new_encryptor(@secret, purpose: 'testing', serialize_json: true)

encrypted_message = encryptor.encrypt({ foo: 'bar' })
decrypted_message = encryptor.decrypt(encrypted_message)

decrypted_message.must_equal({ 'foo' => 'bar' })
end
end

Expand Down

0 comments on commit 6bf68bb

Please sign in to comment.