Skip to content

Commit abfd2cc

Browse files
Forced encoding for deterministic encryption and other improvements (rails#42491)
* Fix: re-encrypting will preserve case when ignore_case is used The implementation was outdated in relation to the new approach where we override accessors. * Add support for exists?(...) when querying encrypted attributes * Add a new option to force encoding for deterministic attributes Different encodings result in different ciphertexts. For deterministically encrypted attributes, this can result in having attributes with the same value that fails to satisfy uniqueness constraints due to having different encodings. This adds a new option `forced_encoding_for_deterministic_encryption:` that will be UTF-8 by default. User can disabled this new behavior by setting the option to nil. * Add changelog entry
1 parent 7893428 commit abfd2cc

File tree

10 files changed

+117
-9
lines changed

10 files changed

+117
-9
lines changed

activerecord/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
* Active Record Encryption will now encode values as UTF-8 when using deterministic
2+
encryption. The encoding is part of the encrypted payload, so different encodings for
3+
different values result in different ciphertexts. This can break unique constraints and
4+
queries.
5+
6+
The new behavior is configurable via `active_record.encryption.forced_encoding_for_deterministic_encryption`
7+
that is `Encoding::UTF_8` by default. It can be disabled by setting it to `nil`.
8+
9+
*Jorge Manrubia*
10+
111
* Disable automatic write protection on replicas.
212

313
Write protection is no longer automatically enabled for replicas. Write protection should be enabled by the database user settings.

activerecord/lib/active_record/encryption/config.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module Encryption
66
class Config
77
attr_accessor :primary_key, :deterministic_key, :store_key_references, :key_derivation_salt,
88
:support_unencrypted_data, :encrypt_fixtures, :validate_column_size, :add_to_filter_parameters,
9-
:excluded_from_filter_parameters, :extend_queries, :previous_schemes
9+
:excluded_from_filter_parameters, :extend_queries, :previous_schemes, :forced_encoding_for_deterministic_encryption
1010

1111
def initialize
1212
set_defaults
@@ -30,6 +30,7 @@ def set_defaults
3030
self.add_to_filter_parameters = true
3131
self.excluded_from_filter_parameters = []
3232
self.previous_schemes = []
33+
self.forced_encoding_for_deterministic_encryption = Encoding::UTF_8
3334

3435
# TODO: Setting to false for now as the implementation is a bit experimental
3536
self.extend_queries = false

activerecord/lib/active_record/encryption/encryptable_record.rb

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,7 @@ def has_encrypted_attributes?
176176

177177
def build_encrypt_attribute_assignments
178178
Array(self.class.encrypted_attributes).index_with do |attribute_name|
179-
if source_attribute_name = self.class.source_attribute_from_preserved_attribute(attribute_name)
180-
self[source_attribute_name]
181-
else
182-
self[attribute_name]
183-
end
179+
self[attribute_name]
184180
end
185181
end
186182

activerecord/lib/active_record/encryption/encrypted_attribute_type.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ def deserialize(value)
3131
end
3232

3333
def serialize(value)
34+
value = force_encoding_if_needed(value)
35+
3436
if serialize_with_oldest?
3537
serialize_with_oldest(value)
3638
else
@@ -49,12 +51,24 @@ def previous_types # :nodoc:
4951
end
5052

5153
private
54+
def force_encoding_if_needed(value)
55+
if deterministic? && forced_encoding_for_deterministic_encryption && value && value.encoding != forced_encoding_for_deterministic_encryption
56+
value.encode(forced_encoding_for_deterministic_encryption, invalid: :replace, undef: :replace)
57+
else
58+
value
59+
end
60+
end
61+
62+
def forced_encoding_for_deterministic_encryption
63+
ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption
64+
end
65+
5266
def previous_schemes_including_clean_text
5367
previous_schemes.including((clean_text_scheme if support_unencrypted_data?)).compact
5468
end
5569

5670
def previous_types_without_clean_text
57-
@previous_types_without_clean_text ||= build_previous_types_for(previous_schemes)
71+
@previous_types_without_clean_text ||= build_previous_types_for(previous_schemes)
5872
end
5973

6074
def build_previous_types_for(schemes)

activerecord/lib/active_record/encryption/extended_deterministic_queries.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ module RelationQueries
8282
include EncryptedQueryArgumentProcessor
8383

8484
def where(*args)
85-
process_encrypted_query_arguments(args, true) unless self.deterministic_encrypted_attributes&.empty?
85+
process_encrypted_query_arguments_if_needed(args)
86+
super
87+
end
88+
89+
def exists?(*args)
90+
process_encrypted_query_arguments_if_needed(args)
8691
super
8792
end
8893

@@ -93,6 +98,11 @@ def find_or_create_by(attributes, &block)
9398
def find_or_create_by!(attributes, &block)
9499
find_by(attributes.dup) || create!(attributes, &block)
95100
end
101+
102+
private
103+
def process_encrypted_query_arguments_if_needed(args)
104+
process_encrypted_query_arguments(args, true) unless self.deterministic_encrypted_attributes&.empty?
105+
end
96106
end
97107

98108
module CoreQueries

activerecord/test/cases/encryption/encryptable_record_api_test.rb

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,22 @@ class ActiveRecord::Encryption::EncryptableRecordApiTest < ActiveRecord::Encrypt
9191
assert_equal encoding, post.reload.title.encoding
9292
end
9393

94+
test "encrypt will honor forced encoding for deterministic attributes" do
95+
ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption = Encoding::UTF_8
96+
97+
book = ActiveRecord::Encryption.without_encryption { EncryptedBook.create!(name: "Dune".encode("US-ASCII")) }
98+
book.encrypt
99+
assert Encoding::UTF_8, book.reload.name.encoding
100+
end
101+
102+
test "encrypt won't force encoding for deterministic attributes when option is nil" do
103+
ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption = nil
104+
105+
book = ActiveRecord::Encryption.without_encryption { EncryptedBook.create!(name: "Dune".encode("US-ASCII")) }
106+
book.encrypt
107+
assert Encoding::US_ASCII, book.reload.name.encoding
108+
end
109+
94110
test "encrypt will preserve case when :ignore_case option is used" do
95111
ActiveRecord::Encryption.config.support_unencrypted_data = true
96112

@@ -101,7 +117,20 @@ class ActiveRecord::Encryption::EncryptableRecordApiTest < ActiveRecord::Encrypt
101117

102118
book.encrypt
103119

120+
assert_equal "Dune", book.reload.name
121+
end
122+
123+
test "re-encrypting will preserve case when :ignore_case option is used" do
124+
ActiveRecord::Encryption.config.support_unencrypted_data = true
125+
126+
book = create_unencrypted_book_ignoring_case name: "Dune"
127+
128+
ActiveRecord::Encryption.without_encryption { assert_equal "Dune", book.reload.name }
104129
assert_equal "Dune", book.name
130+
131+
2.times { book.encrypt }
132+
133+
assert_equal "Dune", book.reload.name
105134
end
106135

107136
test "encrypt attributes encrypted with a previous encryption scheme" do

activerecord/test/cases/encryption/encryptable_record_test.rb

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,30 @@ def name
262262
assert book.name_previously_changed?
263263
end
264264

265+
test "forces UTF-8 encoding for deterministic attributes by default" do
266+
book = EncryptedBook.create!(name: "Dune".encode("ASCII-8BIT"))
267+
assert_equal Encoding::UTF_8, book.reload.name.encoding
268+
end
269+
270+
test "forces encoding for deterministic attributes based on the configured option" do
271+
ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption = Encoding::US_ASCII
272+
273+
book = EncryptedBook.create!(name: "Dune".encode("ASCII-8BIT"))
274+
assert_equal Encoding::US_ASCII, book.reload.name.encoding
275+
end
276+
277+
test "forced encoding for deterministic attributes will replace invalid characters" do
278+
book = EncryptedBook.create!(name: "Hello \x93\xfa".b)
279+
assert_equal "Hello ��", book.reload.name
280+
end
281+
282+
test "forced encoding for deterministic attributes can be disabled" do
283+
ActiveRecord::Encryption.config.forced_encoding_for_deterministic_encryption = nil
284+
285+
book = EncryptedBook.create!(name: "Dune".encode("US-ASCII"))
286+
assert_equal Encoding::US_ASCII, book.reload.name.encoding
287+
end
288+
265289
private
266290
class FailingKeyProvider
267291
def decryption_key(message) end

activerecord/test/cases/encryption/extended_deterministic_queries_test.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,9 @@ class ActiveRecord::Encryption::ExtendedDeterministicQueriesTest < ActiveRecord:
3232
EncryptedBook.find_or_create_by!(name: "Dune")
3333
assert EncryptedBook.find_by(name: "Dune")
3434
end
35+
36+
test "exists?(...) works" do
37+
ActiveRecord::Encryption.without_encryption { EncryptedBook.create! name: "Dune" }
38+
assert EncryptedBook.exists?(name: "Dune")
39+
end
3540
end

activerecord/test/cases/encryption/helper.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ class ActiveRecord::EncryptionTestCase < ActiveRecord::TestCase
144144
# , PerformanceHelpers
145145

146146
ENCRYPTION_ATTRIBUTES_TO_RESET = %i[ primary_key deterministic_key key_derivation_salt store_key_references
147-
key_derivation_salt support_unencrypted_data encrypt_fixtures ]
147+
key_derivation_salt support_unencrypted_data encrypt_fixtures forced_encoding_for_deterministic_encryption ]
148148

149149
setup do
150150
ENCRYPTION_ATTRIBUTES_TO_RESET.each do |property|

guides/source/active_record_encryption.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,24 @@ config.active_record.encryption.add_to_filter_parameters = false
240240
```
241241
In case you want exclude specific columns from this automatic filtering, add them to `config.active_record.encryption.excluded_from_filter_parameters`.
242242

243+
### Encoding
244+
245+
The library will preserve the encoding for string values encrypted non-deterministically.
246+
247+
For values encrypted deterministically, by default, the library will force UTF-8 encoding. The reason is that encoding is stored along with the encrypted payload. This means that the same value with a different encoding will result in different ciphertexts when encrypted. You normally want to avoid this to keep queries and uniqueness constraints working, so the library will perform the conversion automatically on your behalf.
248+
249+
You can configure the desired default encoding for deterministic encryption with:
250+
251+
```ruby
252+
config.active_record.encryption.forced_encoding_for_deterministic_encryption = Encoding::US_ASCII
253+
```
254+
255+
And you can disable this behavior and preserve the encoding in all cases with:
256+
257+
```ruby
258+
config.active_record.encryption.forced_encoding_for_deterministic_encryption = nil
259+
```
260+
243261
## Key management
244262

245263
Key management strategies are implemented by key providers. You can configure key providers globally or on a per-attribute basis.
@@ -404,6 +422,7 @@ The available config options are:
404422
| `primary_key` | The key or lists of keys that is used to derive root data-encryption keys. They way they are used depends on the key provider configured. It's preferred to configure it via a credential `active_record_encryption.primary_key`. |
405423
| `deterministic_key` | The key or list of keys used for deterministic encryption. It's preferred to configure it via a credential `active_record_encryption.deterministic_key`. |
406424
| `key_derivation_salt` | The salt used when deriving keys. It's preferred to configure it via a credential `active_record_encryption.key_derivation_salt`. |
425+
| `forced_encoding_for_deterministic_encryption` | The default encoding for attributes encrypted deterministically. You can disable forced encoding by setting this option to `nil`. It's `Encoding::UTF_8` by default. |
407426
408427
NOTE: It's recommended to use Rails built-in credentials support to store keys. If you prefer to set them manually via config properties, make sure you don't commit them with your code (e.g: use environment variables).
409428

0 commit comments

Comments
 (0)