Skip to content

Commit a331e31

Browse files
committed
Fix binary decryption on Postgres
When encrypting attributes we need to do it just before inserting the data into the database, so after any other serialization steps, e.g. for serialized types or normalization. And we need things to happen in reverse order when decrypting. With attribute decoration we end up with initial type nested in other types. To ensure that the encryption happens in the right place, the EncryptedAttributeType first serializes the value with the type it is wrapping and then encrypts it. And in reverse it decrypts then deserializes with the wrapped type. There's an assumption here, which is that the wrapped type doesn't need to do anything in between the database and the encryption layer - so any database specific casting is skipped. This works fine for String columns as there's nothing for them to do. It also works for binary columns for MySQL and SQLite. But not for PostgreSQL which needs to receive the data as Binary::Data and has to call `PG::Connection.unescape_bytea` when deserializing the data. The serialization part was fixed in rails#50920, where the encryption output is wrapped in Binary::Data, which let's the PostgreSQL adapter know to convert the value [here](https://github.com/rails/rails/blob/5a0b2fa5a3be6ffd49fa7f1c3544c1da403c9cb5/activerecord/lib/active_record/connection_adapters/abstract/quoting.rb#L83). That PR however didn't fix deserializing the data when it comes back out of the database (it wasn't round-tripping the data properly in the tests). We need to deserialize binary types before decrypting them - and we'll have to just assume that the wrapped type can do that for us. This won't work for serialized types as they'll also attempt to convert the data with the coder which needs to happen after decryption, so we need to special case them and extract the subtype instead. This isn't ideal but it should work ok for all built in types. But if you have a custom binary type that does something that needs to happen both before and after decryption then it may not work as expected. I think that's a rare case though and it's more important to have encryption of binary types working on PostgreSQL.
1 parent f0fd6ab commit a331e31

File tree

6 files changed

+85
-9
lines changed

6 files changed

+85
-9
lines changed

activerecord/CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
* Deserialize binary data before decrypting
2+
3+
This ensures that we call `PG::Connection.unescape_bytea` on PostgreSQL before decryption.
4+
5+
*Donal McBreen*
6+
17
* Ensure `ActiveRecord::Encryption.config` is always ready before access.
28

39
Previously, `ActiveRecord::Encryption` configuration was deferred until `ActiveRecord::Base`

activerecord/lib/active_record/encryption/encrypted_attribute_type.rb

+10-1
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ def decrypt_as_text(value)
100100
end
101101

102102
def decrypt(value)
103-
text_to_database_type decrypt_as_text(value)
103+
text_to_database_type decrypt_as_text(database_type_to_text(value))
104104
end
105105

106106
def try_to_deserialize_with_previous_encrypted_types(value)
@@ -170,6 +170,15 @@ def text_to_database_type(value)
170170
value
171171
end
172172
end
173+
174+
def database_type_to_text(value)
175+
if value && cast_type.binary?
176+
binary_cast_type = cast_type.serialized? ? cast_type.subtype : cast_type
177+
binary_cast_type.deserialize(value)
178+
else
179+
value
180+
end
181+
end
173182
end
174183
end
175184
end

activerecord/test/cases/encryption/encryptable_record_message_pack_serialized_test.rb

+7-4
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,22 @@
55
require "models/book_encrypted"
66
require "active_record/encryption/message_pack_message_serializer"
77

8-
class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::EncryptionTestCase
8+
class ActiveRecord::Encryption::EncryptableRecordMessagePackSerializedTest < ActiveRecord::EncryptionTestCase
99
fixtures :encrypted_books
1010

1111
test "binary data can be serialized with message pack" do
1212
all_bytes = (0..255).map(&:chr).join
13-
assert_equal all_bytes, EncryptedBookWithBinaryMessagePackSerialized.create!(logo: all_bytes).logo
13+
book = EncryptedBookWithBinaryMessagePackSerialized.create!(logo: all_bytes)
14+
assert_encrypted_attribute(book, :logo, all_bytes)
1415
end
1516

1617
test "binary data can be encrypted uncompressed and serialized with message pack" do
18+
# Strings below 140 bytes are not compressed
1719
low_bytes = (0..127).map(&:chr).join
1820
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+
22+
assert_encrypted_attribute(EncryptedBookWithBinaryMessagePackSerialized.create!(logo: low_bytes), :logo, low_bytes)
23+
assert_encrypted_attribute(EncryptedBookWithBinaryMessagePackSerialized.create!(logo: high_bytes), :logo, high_bytes)
2124
end
2225

2326
test "text columns cannot be serialized with message pack" do

activerecord/test/cases/encryption/encryptable_record_test.rb

+21-3
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,12 @@ class ActiveRecord::Encryption::EncryptableRecordTest < ActiveRecord::Encryption
9292
assert_encrypted_attribute(traffic_light, :state, states)
9393
end
9494

95+
test "encrypts serialized attributes where encrypts is declared first" do
96+
states = ["green", "red"]
97+
traffic_light = EncryptedFirstTrafficLight.create!(state: states, long_state: states)
98+
assert_encrypted_attribute(traffic_light, :state, states)
99+
end
100+
95101
test "encrypts store attributes with accessors" do
96102
traffic_light = EncryptedTrafficLightWithStoreState.create!(color: "red", long_state: ["green", "red"])
97103
assert_equal "red", traffic_light.color
@@ -404,13 +410,14 @@ def name
404410
test "binary data can be encrypted uncompressed" do
405411
low_bytes = (0..127).map(&:chr).join
406412
high_bytes = (128..255).map(&:chr).join
407-
assert_equal low_bytes, EncryptedBookWithBinary.create!(logo: low_bytes).logo
408-
assert_equal high_bytes, EncryptedBookWithBinary.create!(logo: high_bytes).logo
413+
assert_encrypted_attribute EncryptedBookWithBinary.create!(logo: low_bytes), :logo, low_bytes
414+
assert_encrypted_attribute EncryptedBookWithBinary.create!(logo: high_bytes), :logo, high_bytes
409415
end
410416

411417
test "serialized binary data can be encrypted" do
412418
json_bytes = (32..127).map(&:chr)
413-
assert_equal json_bytes, EncryptedBookWithSerializedBinary.create!(logo: json_bytes).logo
419+
assert_encrypted_attribute EncryptedBookWithSerializedFirstBinary.create!(logo: json_bytes), :logo, json_bytes
420+
assert_encrypted_attribute EncryptedBookWithSerializedSecondBinary.create!(logo: json_bytes), :logo, json_bytes
414421
end
415422

416423
test "can compress data with custom compressor" do
@@ -423,6 +430,17 @@ def name
423430
assert_equal :text, EncryptedPost.type_for_attribute(:body).type
424431
end
425432

433+
test "encrypts normalized data" do
434+
assert_encrypted_attribute EncryptedBookNormalizedFirst.create!(name: "Book"), :name, "book"
435+
assert_encrypted_attribute EncryptedBookNormalizedSecond.create!(name: "Book"), :name, "book"
436+
assert_encrypted_attribute EncryptedBookNormalizedFirst.create!(logo: "Book"), :logo, "book"
437+
assert_encrypted_attribute EncryptedBookNormalizedSecond.create!(logo: "Book"), :logo, "book"
438+
end
439+
440+
test "encrypts attribute data" do
441+
assert_encrypted_attribute EncryptedBookAttribute.create!(name: "2024-01-01"), :name, Date.new(2024, 1, 1)
442+
end
443+
426444
private
427445
def build_derived_key_provider_with(hash_digest_class)
428446
ActiveRecord::Encryption.with_encryption_context(key_generator: ActiveRecord::Encryption::KeyGenerator.new(hash_digest_class: hash_digest_class)) do

activerecord/test/models/book_encrypted.rb

+33-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@ class EncryptedBookWithDowncaseName < ActiveRecord::Base
2424
encrypts :name, deterministic: true, downcase: true
2525
end
2626

27+
class EncryptedBookNormalizedFirst < ActiveRecord::Base
28+
self.table_name = "encrypted_books"
29+
30+
normalizes :name, with: ->(value) { value.to_s.downcase }
31+
encrypts :name
32+
normalizes :logo, with: ->(value) { value.to_s.downcase }
33+
encrypts :logo
34+
end
35+
36+
class EncryptedBookNormalizedSecond < ActiveRecord::Base
37+
self.table_name = "encrypted_books"
38+
39+
encrypts :name
40+
normalizes :name, with: ->(value) { value.to_s.downcase }
41+
encrypts :logo
42+
normalizes :logo, with: ->(value) { value.to_s.downcase }
43+
end
44+
45+
class EncryptedBookAttribute < ActiveRecord::Base
46+
self.table_name = "encrypted_books"
47+
48+
attribute :name, :date
49+
encrypts :name
50+
end
51+
2752
class EncryptedBookThatIgnoresCase < ActiveRecord::Base
2853
self.table_name = "encrypted_books"
2954

@@ -50,13 +75,20 @@ class EncryptedBookWithBinary < ActiveRecord::Base
5075
encrypts :logo
5176
end
5277

53-
class EncryptedBookWithSerializedBinary < ActiveRecord::Base
78+
class EncryptedBookWithSerializedFirstBinary < ActiveRecord::Base
5479
self.table_name = "encrypted_books"
5580

5681
serialize :logo, coder: JSON
5782
encrypts :logo
5883
end
5984

85+
class EncryptedBookWithSerializedSecondBinary < ActiveRecord::Base
86+
self.table_name = "encrypted_books"
87+
88+
encrypts :logo
89+
serialize :logo, coder: JSON
90+
end
91+
6092
class EncryptedBookWithCustomCompressor < ActiveRecord::Base
6193
module CustomCompressor
6294
def self.deflate(value)

activerecord/test/models/traffic_light_encrypted.rb

+8
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ class EncryptedTrafficLight < TrafficLight
77
encrypts :state
88
end
99

10+
class EncryptedFirstTrafficLight < ActiveRecord::Base
11+
self.table_name = "traffic_lights"
12+
13+
encrypts :state
14+
serialize :state, type: Array
15+
serialize :long_state, type: Array
16+
end
17+
1018
class EncryptedTrafficLightWithStoreState < TrafficLight
1119
store :state, accessors: %i[ color ], coder: ActiveRecord::Coders::JSON
1220
encrypts :state

0 commit comments

Comments
 (0)