Skip to content

Commit 50e3229

Browse files
authored
Add MessagePackMessageSerializer for binary data (rails#51102)
1 parent c676398 commit 50e3229

File tree

6 files changed

+195
-1
lines changed

6 files changed

+195
-1
lines changed

activerecord/CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
* Add ActiveRecord::Encryption::MessagePackMessageSerializer
2+
3+
Serialize data to the MessagePack format, for efficient storage in binary columns.
4+
5+
The binary encoding requires around 30% less space than the base64 encoding
6+
used by the default serializer.
7+
8+
*Donal McBreen*
9+
110
* Add support for encrypting binary columns
211

312
Ensure encryption and decryption pass `Type::Binary::Data` around for binary data.

activerecord/fixtures/journal_mode_test.sqlite3

Whitespace-only changes.

activerecord/lib/active_record/encryption/encrypted_attribute_type.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,11 @@ def serialize_with_current(value)
135135

136136
def encrypt_as_text(value)
137137
with_context do
138-
encryptor.encrypt(value, **encryption_options)
138+
encryptor.encrypt(value, **encryption_options).tap do |encrypted|
139+
if !cast_type.binary? && encrypted.encoding == Encoding::BINARY
140+
raise Errors::Encoding, "Binary encoded data can only be stored in binary columns"
141+
end
142+
end
139143
end
140144
end
141145

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# frozen_string_literal: true
2+
3+
require "active_support/message_pack"
4+
5+
module ActiveRecord
6+
module Encryption
7+
# A message serializer that serializes +Messages+ with MessagePack.
8+
#
9+
# The message is converted to a hash with this structure:
10+
#
11+
# {
12+
# p: <payload>,
13+
# h: {
14+
# header1: value1,
15+
# header2: value2,
16+
# ...
17+
# }
18+
# }
19+
#
20+
# Then it is converted to the MessagePack format.
21+
class MessagePackMessageSerializer
22+
def dump(message)
23+
raise Errors::ForbiddenClass unless message.is_a?(Message)
24+
ActiveSupport::MessagePack.dump(message_to_hash(message))
25+
end
26+
27+
def load(serialized_content)
28+
data = ActiveSupport::MessagePack.load(serialized_content)
29+
hash_to_message(data, 1)
30+
rescue RuntimeError
31+
raise Errors::Decryption
32+
end
33+
34+
private
35+
def message_to_hash(message)
36+
{
37+
"p" => message.payload,
38+
"h" => headers_to_hash(message.headers)
39+
}
40+
end
41+
42+
def headers_to_hash(headers)
43+
headers.transform_values do |value|
44+
value.is_a?(Message) ? message_to_hash(value) : value
45+
end
46+
end
47+
48+
def hash_to_message(data, level)
49+
validate_message_data_format(data, level)
50+
Message.new(payload: data["p"], headers: parse_properties(data["h"], level))
51+
end
52+
53+
def validate_message_data_format(data, level)
54+
if level > 2
55+
raise Errors::Decryption, "More than one level of hash nesting in headers is not supported"
56+
end
57+
58+
unless data.is_a?(Hash) && data.has_key?("p")
59+
raise Errors::Decryption, "Invalid data format: hash without payload"
60+
end
61+
end
62+
63+
def parse_properties(headers, level)
64+
Properties.new.tap do |properties|
65+
headers&.each do |key, value|
66+
properties[key] = value.is_a?(Hash) ? hash_to_message(value, level + 1) : value
67+
end
68+
end
69+
end
70+
end
71+
end
72+
end
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/encryption/helper"
4+
require "models/author_encrypted"
5+
require "models/book_encrypted"
6+
require "active_record/encryption/message_pack_message_serializer"
7+
8+
class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::EncryptionTestCase
9+
fixtures :encrypted_books
10+
11+
test "binary data can be serialized with message pack" do
12+
all_bytes = (0..255).map(&:chr).join
13+
assert_equal all_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: all_bytes).logo
14+
end
15+
16+
test "binary data can be encrypted uncompressed and serialized with message pack" do
17+
low_bytes = (0..127).map(&:chr).join
18+
high_bytes = (128..255).map(&:chr).join
19+
assert_equal low_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: low_bytes).logo
20+
assert_equal high_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: high_bytes).logo
21+
end
22+
23+
test "text columns cannot be serialized with message pack" do
24+
assert_raises(ActiveRecord::Encryption::Errors::Encoding) do
25+
message_pack_serialized_text_class = Class.new(ActiveRecord::Base) do
26+
self.table_name = "encrypted_books"
27+
28+
encrypts :name, message_serializer: ActiveRecord::Encryption::MessagePackMessageSerializer.new
29+
end
30+
message_pack_serialized_text_class.create(name: "Dune")
31+
end
32+
end
33+
34+
class EncryptedBookWithBinaryMessagePackSerialized < ActiveRecord::Base
35+
self.table_name = "encrypted_books"
36+
37+
encrypts :logo, message_serializer: ActiveRecord::Encryption::MessagePackMessageSerializer.new
38+
end
39+
end
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/encryption/helper"
4+
require "base64"
5+
require "active_record/encryption/message_pack_message_serializer"
6+
7+
class ActiveRecord::Encryption::MessagePackMessageSerializerTest < ActiveRecord::EncryptionTestCase
8+
setup do
9+
@serializer = ActiveRecord::Encryption::MessagePackMessageSerializer.new
10+
end
11+
12+
test "serializes messages" do
13+
message = build_message
14+
deserialized_message = serialize_and_deserialize(message)
15+
assert_equal message, deserialized_message
16+
end
17+
18+
test "serializes messages with nested messages in their headers" do
19+
message = build_message
20+
message.headers[:other_message] = ActiveRecord::Encryption::Message.new(payload: "some other secret payload", headers: { some_header: "some other value" })
21+
22+
deserialized_message = serialize_and_deserialize(message)
23+
assert_equal message, deserialized_message
24+
end
25+
26+
test "detects random data and raises a decryption error" do
27+
assert_raises ActiveRecord::Encryption::Errors::Decryption do
28+
@serializer.load "hey there"
29+
end
30+
end
31+
32+
test "detects random JSON hashes and raises a decryption error" do
33+
assert_raises ActiveRecord::Encryption::Errors::Decryption do
34+
@serializer.load JSON.dump({ some: "other data" })
35+
end
36+
end
37+
38+
test "raises a TypeError when trying to deserialize other data types" do
39+
assert_raises TypeError do
40+
@serializer.load(:it_can_only_deserialize_strings)
41+
end
42+
end
43+
44+
test "raises ForbiddenClass when trying to serialize other data types" do
45+
assert_raises ActiveRecord::Encryption::Errors::ForbiddenClass do
46+
@serializer.dump("it can only serialize messages!")
47+
end
48+
end
49+
50+
test "raises Decryption when trying to parse message with more than one nested message" do
51+
message = build_message
52+
message.headers[:other_message] = ActiveRecord::Encryption::Message.new(payload: "some other secret payload", headers: { some_header: "some other value" })
53+
message.headers[:other_message].headers[:yet_another_message] = ActiveRecord::Encryption::Message.new(payload: "yet some other secret payload", headers: { some_header: "yet some other value" })
54+
55+
assert_raises ActiveRecord::Encryption::Errors::Decryption do
56+
serialize_and_deserialize(message)
57+
end
58+
end
59+
60+
private
61+
def build_message
62+
payload = "some payload"
63+
headers = { key_1: "1" }
64+
ActiveRecord::Encryption::Message.new(payload: payload, headers: headers)
65+
end
66+
67+
def serialize_and_deserialize(message, with: @serializer)
68+
@serializer.load @serializer.dump(message)
69+
end
70+
end

0 commit comments

Comments
 (0)