active_storage_encryption 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +7 -0
  2. data/Appraisals +7 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +236 -0
  5. data/Rakefile +17 -0
  6. data/bin/rails +26 -0
  7. data/bin/rubocop +8 -0
  8. data/config/initializers/active_storage_encryption.rb +9 -0
  9. data/config/routes.rb +7 -0
  10. data/gemfiles/rails_7.gemfile +7 -0
  11. data/gemfiles/rails_7.gemfile.lock +276 -0
  12. data/gemfiles/rails_8.gemfile +7 -0
  13. data/gemfiles/rails_8.gemfile.lock +276 -0
  14. data/lib/active_storage/service/encrypted_disk_service.rb +10 -0
  15. data/lib/active_storage/service/encrypted_mirror_service.rb +10 -0
  16. data/lib/active_storage/service/encrypted_s3_service.rb +10 -0
  17. data/lib/active_storage_encryption/encrypted_blobs_controller.rb +163 -0
  18. data/lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb +28 -0
  19. data/lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb +51 -0
  20. data/lib/active_storage_encryption/encrypted_disk_service.rb +186 -0
  21. data/lib/active_storage_encryption/encrypted_mirror_service.rb +76 -0
  22. data/lib/active_storage_encryption/encrypted_s3_service.rb +236 -0
  23. data/lib/active_storage_encryption/engine.rb +7 -0
  24. data/lib/active_storage_encryption/overrides.rb +201 -0
  25. data/lib/active_storage_encryption/private_url_policy.rb +53 -0
  26. data/lib/active_storage_encryption/resumable_gcs_upload.rb +194 -0
  27. data/lib/active_storage_encryption/version.rb +5 -0
  28. data/lib/active_storage_encryption.rb +79 -0
  29. data/lib/tasks/active_storage_encryption_tasks.rake +6 -0
  30. data/test/active_storage_encryption_test.rb +9 -0
  31. data/test/dummy/Rakefile +8 -0
  32. data/test/dummy/app/assets/stylesheets/application.css +1 -0
  33. data/test/dummy/app/controllers/application_controller.rb +6 -0
  34. data/test/dummy/app/helpers/application_helper.rb +4 -0
  35. data/test/dummy/app/models/application_record.rb +5 -0
  36. data/test/dummy/app/views/layouts/application.html.erb +22 -0
  37. data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
  38. data/test/dummy/app/views/pwa/service-worker.js +26 -0
  39. data/test/dummy/bin/rails +4 -0
  40. data/test/dummy/bin/rake +4 -0
  41. data/test/dummy/bin/setup +37 -0
  42. data/test/dummy/config/application.rb +43 -0
  43. data/test/dummy/config/boot.rb +7 -0
  44. data/test/dummy/config/credentials.yml.enc +1 -0
  45. data/test/dummy/config/database.yml +32 -0
  46. data/test/dummy/config/environment.rb +7 -0
  47. data/test/dummy/config/environments/development.rb +59 -0
  48. data/test/dummy/config/environments/production.rb +81 -0
  49. data/test/dummy/config/environments/test.rb +53 -0
  50. data/test/dummy/config/initializers/content_security_policy.rb +27 -0
  51. data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
  52. data/test/dummy/config/initializers/inflections.rb +18 -0
  53. data/test/dummy/config/initializers/permissions_policy.rb +15 -0
  54. data/test/dummy/config/locales/en.yml +31 -0
  55. data/test/dummy/config/master.key +1 -0
  56. data/test/dummy/config/puma.rb +36 -0
  57. data/test/dummy/config/routes.rb +5 -0
  58. data/test/dummy/config/storage.yml +21 -0
  59. data/test/dummy/config.ru +8 -0
  60. data/test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb +60 -0
  61. data/test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb +7 -0
  62. data/test/dummy/db/schema.rb +47 -0
  63. data/test/dummy/log/test.log +1022 -0
  64. data/test/dummy/public/404.html +67 -0
  65. data/test/dummy/public/406-unsupported-browser.html +66 -0
  66. data/test/dummy/public/422.html +67 -0
  67. data/test/dummy/public/500.html +66 -0
  68. data/test/dummy/public/icon.png +0 -0
  69. data/test/dummy/public/icon.svg +3 -0
  70. data/test/dummy/storage/test.sqlite3 +0 -0
  71. data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
  72. data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
  73. data/test/integration/encrypted_blobs_controller_test.rb +400 -0
  74. data/test/lib/encrypted_disk_service_test.rb +387 -0
  75. data/test/lib/encrypted_mirror_service_test.rb +159 -0
  76. data/test/lib/encrypted_s3_service_test.rb +293 -0
  77. data/test/test_helper.rb +19 -0
  78. metadata +264 -0
@@ -0,0 +1,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryption::EncryptedDiskServiceTest < ActiveSupport::TestCase
6
+ def setup
7
+ @storage_dir = Dir.mktmpdir
8
+ @service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir)
9
+ @service.name = "amazing_encrypting_disk_service" # Needed for the DiskController and service lookup
10
+ @previous_default_service = ActiveStorage::Blob.service
11
+
12
+ # The EncryptedDiskService generates URLs by itself, so it needs
13
+ # ActiveStorage::Current.url_options to be set
14
+ # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
15
+ # see https://stackoverflow.com/a/60573259/153886
16
+ ActiveStorage::Current.url_options = {
17
+ host: "www.example.com",
18
+ protocol: "https"
19
+ }
20
+ end
21
+
22
+ def teardown
23
+ FileUtils.rm_rf(@storage_dir)
24
+ ActiveStorage::Blob.service = @previous_default_service
25
+ end
26
+
27
+ def test_headers_for_direct_upload
28
+ key = "key-1"
29
+ k = Random.bytes(68)
30
+ md5 = Digest::MD5.base64digest("x")
31
+ headers = @service.headers_for_direct_upload(key, content_type: "image/jpeg", encryption_key: k, checksum: md5)
32
+ assert_equal headers["x-active-storage-encryption-key"], Base64.strict_encode64(k)
33
+ assert_equal headers["content-md5"], md5
34
+ end
35
+
36
+ def test_upload_with_checksum
37
+ # We need to test this to make sure the checksum gets verified after decryption
38
+ key = "key-1"
39
+ k = Random.bytes(68)
40
+ plaintext_upload_bytes = generate_random_binary_string
41
+
42
+ incorrect_base64_md5 = Digest::MD5.base64digest("Something completely different")
43
+ assert_raises(ActiveStorage::IntegrityError) do
44
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: incorrect_base64_md5)
45
+ end
46
+ refute @service.exist?(key)
47
+
48
+ correct_base64_md5 = Digest::MD5.base64digest(plaintext_upload_bytes)
49
+ assert_nothing_raised do
50
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: correct_base64_md5)
51
+ end
52
+ assert @service.exist?(key)
53
+ end
54
+
55
+ def test_put_via_controller
56
+ key = "key-1"
57
+ k = Random.bytes(68)
58
+ plaintext_upload_bytes = generate_random_binary_string
59
+
60
+ ActiveStorage::Blob.service = @service # So that the controller can find it
61
+ b64md5 = Digest::MD5.base64digest(plaintext_upload_bytes)
62
+
63
+ url = @service.url_for_direct_upload(key, expires_in: 60.seconds, content_type: "binary/octet-stream", content_length: plaintext_upload_bytes.bytesize, checksum: b64md5, encryption_key: k, custom_metadata: {})
64
+ assert url.include?("/active-storage-encryption/blob/")
65
+
66
+ uri = URI.parse(url)
67
+ # Do a super-minimalistic test on the DiskController. ActionController is actually a Rack app (or, rather: every controller action is a Rack app).
68
+ # It can thus be called with a minimal Rack env. For the definition of "minimal", see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment-
69
+ rack_env = {
70
+ "SCRIPT_NAME" => "",
71
+ "PATH_INFO" => uri.path,
72
+ "QUERY_STRING" => uri.query,
73
+ "REQUEST_METHOD" => "PUT",
74
+ "SERVER_NAME" => uri.host,
75
+ "rack.input" => StringIO.new(plaintext_upload_bytes),
76
+ "CONTENT_LENGTH" => plaintext_upload_bytes.bytesize.to_s(10),
77
+ "CONTENT_TYPE" => "binary/octet-stream",
78
+ "HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(k),
79
+ "HTTP_CONTENT_MD5" => Digest::MD5.base64digest(plaintext_upload_bytes),
80
+ "action_dispatch.request.parameters" => {
81
+ # The controller expects the Rails router to have injected this param by extracting
82
+ # it from the route path
83
+ "token" => uri.path.split("/").last
84
+ }
85
+ }
86
+ action_app = ActiveStorageEncryption::EncryptedBlobsController.action(:update)
87
+ status, _headers, _body = action_app.call(rack_env)
88
+ assert_equal 204, status # "Accepted"
89
+
90
+ readback_bytes = @service.download(key, encryption_key: k)
91
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_bytes)
92
+ end
93
+
94
+ def test_get_with_headers_always_succeeds
95
+ @service.private_url_policy = :require_headers
96
+
97
+ key = "key-1"
98
+ k = Random.bytes(68)
99
+ plaintext_upload_bytes = generate_random_binary_string
100
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
101
+
102
+ ActiveStorage::Blob.service = @service # So that the controller can find it
103
+
104
+ # ActiveStorage wraps the passed filename in a wrapper thingy
105
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
106
+ url = @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds)
107
+ assert url.include?("/active-storage-encryption/blob/")
108
+
109
+ uri = URI.parse(url)
110
+
111
+ # Do a super-minimalistic test on the DiskController. ActionController is actually a Rack app (or, rather: every controller action is a Rack app).
112
+ # It can thus be called with a minimal Rack env. For the definition of "minimal", see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment-
113
+ rack_env = {
114
+ "SCRIPT_NAME" => "",
115
+ "PATH_INFO" => uri.path,
116
+ "QUERY_STRING" => uri.query,
117
+ "REQUEST_METHOD" => "GET",
118
+ "SERVER_NAME" => uri.host,
119
+ "HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(k),
120
+ "rack.input" => StringIO.new(""),
121
+ "action_dispatch.request.parameters" => {
122
+ # The controller expects the Rails router to have injected this param by extracting
123
+ # it from the route path. The upload param is mapped to :encoded_token, the download param is
124
+ # mapped to :encoded_key - likely because there was an exploit with ActiveStorage where keys
125
+ # generated for download could be used for uploading (and thus - overwriting)
126
+ "token" => uri.path.split("/")[-2] # For "show", the last path param is actually the filename - this is because Content-Disposition can be unreliable for download filename
127
+ }
128
+ }
129
+ action_app = ActiveStorageEncryption::EncryptedBlobsController.action(:show)
130
+ status, _headers, body = action_app.call(rack_env)
131
+
132
+ assert_equal 200, status
133
+
134
+ readback_bytes = (+"").b.tap do |buf|
135
+ body.each { |chunk| buf << chunk }
136
+ end
137
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_bytes)
138
+ end
139
+
140
+ def test_get_without_headers_succeeds_if_service_permits
141
+ @service.private_url_policy = :stream
142
+
143
+ key = "key-1"
144
+ k = Random.bytes(68)
145
+ plaintext_upload_bytes = generate_random_binary_string
146
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
147
+
148
+ ActiveStorage::Blob.service = @service # So that the controller can find it
149
+
150
+ # ActiveStorage wraps the passed filename in a wrapper thingy
151
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
152
+ url = @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds)
153
+ assert url.include?("/active-storage-encryption/blob/")
154
+
155
+ uri = URI.parse(url)
156
+
157
+ # Do a super-minimalistic test on the DiskController. ActionController is actually a Rack app (or, rather: every controller action is a Rack app).
158
+ # It can thus be called with a minimal Rack env. For the definition of "minimal", see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment-
159
+ rack_env = {
160
+ "SCRIPT_NAME" => "",
161
+ "PATH_INFO" => uri.path,
162
+ "QUERY_STRING" => uri.query,
163
+ "REQUEST_METHOD" => "GET",
164
+ "SERVER_NAME" => uri.host,
165
+ "rack.input" => StringIO.new(""),
166
+ "action_dispatch.request.parameters" => {
167
+ # The controller expects the Rails router to have injected this param by extracting
168
+ # it from the route path. The upload param is mapped to :encoded_token, the download param is
169
+ # mapped to :encoded_key - likely because there was an exploit with ActiveStorage where keys
170
+ # generated for download could be used for uploading (and thus - overwriting)
171
+ "token" => uri.path.split("/")[-2] # For "show", the last path param is actually the filename - this is because Content-Disposition can be unreliable for download filename
172
+ }
173
+ }
174
+ action_app = ActiveStorageEncryption::EncryptedBlobsController.action(:show)
175
+ status, _headers, body = action_app.call(rack_env)
176
+
177
+ assert_equal 200, status
178
+
179
+ readback_bytes = (+"").b.tap do |buf|
180
+ body.each { |chunk| buf << chunk }
181
+ end
182
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_bytes)
183
+ end
184
+
185
+ def test_generating_url_fails_if_streaming_is_off_for_the_service
186
+ @service.private_url_policy = :disable
187
+
188
+ key = "key-1"
189
+ k = Random.bytes(68)
190
+ plaintext_upload_bytes = generate_random_binary_string
191
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
192
+
193
+ # ActiveStorage wraps the passed filename in a wrapper thingy
194
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
195
+ assert_raises ActiveStorageEncryption::StreamingDisabled do
196
+ @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds)
197
+ end
198
+ end
199
+
200
+ def test_get_without_headers_fails_if_service_does_not_permit
201
+ @service.private_url_policy = :require_headers
202
+
203
+ key = "key-1"
204
+ k = Random.bytes(68)
205
+ plaintext_upload_bytes = generate_random_binary_string
206
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
207
+
208
+ ActiveStorage::Blob.service = @service # So that the controller can find it
209
+
210
+ # ActiveStorage wraps the passed filename in a wrapper thingy
211
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
212
+ url = @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds)
213
+ uri = URI.parse(url)
214
+
215
+ # Do a super-minimalistic test on the DiskController. ActionController is actually a Rack app (or, rather: every controller action is a Rack app).
216
+ # It can thus be called with a minimal Rack env. For the definition of "minimal", see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment-
217
+ rack_env = {
218
+ "SCRIPT_NAME" => "",
219
+ "PATH_INFO" => uri.path,
220
+ "QUERY_STRING" => uri.query,
221
+ "REQUEST_METHOD" => "GET",
222
+ "SERVER_NAME" => uri.host,
223
+ "rack.input" => StringIO.new(""),
224
+ # Omit x-disk-encryption-key
225
+ "action_dispatch.request.parameters" => {
226
+ "token" => uri.path.split("/")[-2] # For "show", the last path param is actually the filename - this is because Content-Disposition can be unreliable for download filename
227
+ }
228
+ }
229
+ action_app = ActiveStorageEncryption::EncryptedBlobsController.action(:show)
230
+ status, _headers, _body = action_app.call(rack_env)
231
+ assert_equal 403, status
232
+ end
233
+
234
+ def test_upload_then_download_using_correct_key
235
+ storage_blob_key = "key-1"
236
+ k = Random.bytes(68)
237
+ plaintext_upload_bytes = generate_random_binary_string
238
+
239
+ assert_nothing_raised do
240
+ @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
241
+ end
242
+
243
+ assert @service.exist?(storage_blob_key)
244
+
245
+ encrypted_file_paths = Dir.glob(@storage_dir + "/**/*.encrypted-*").sort
246
+ readback_encrypted_bytes = File.binread(encrypted_file_paths.last)
247
+
248
+ # Make sure the output is, indeed, encrypted
249
+ refute_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_encrypted_bytes)
250
+
251
+ # Readback the entire file, decrypting it
252
+ readback_plaintext_bytes = (+"").b
253
+ @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes }
254
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes)
255
+
256
+ # Test random access
257
+ from_offset = Random.rand(0..999)
258
+ chunk_size = Random.rand(0..1024)
259
+ range = (from_offset..(from_offset + chunk_size))
260
+ chunk_from_upload = plaintext_upload_bytes[range]
261
+ assert_equal chunk_from_upload, @service.download_chunk(storage_blob_key, range, encryption_key: k)
262
+ end
263
+
264
+ def test_upload_requires_key_of_certain_length
265
+ storage_blob_key = "key-1"
266
+ k = Random.bytes(12)
267
+ plaintext_upload_bytes = generate_random_binary_string
268
+
269
+ assert_raises(ArgumentError) do
270
+ @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
271
+ end
272
+ end
273
+
274
+ def test_upload_then_download_using_user_supplied_key_of_arbitrary_length
275
+ storage_blob_key = "key-1"
276
+ k = Random.new(Minitest.seed).bytes(128)
277
+ plaintext_upload_bytes = generate_random_binary_string
278
+
279
+ assert_nothing_raised do
280
+ @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
281
+ end
282
+ assert @service.exist?(storage_blob_key)
283
+
284
+ # Readback the entire file, decrypting it
285
+ readback_plaintext_bytes = (+"").b
286
+ @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes }
287
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes)
288
+ end
289
+
290
+ def test_upload_via_older_encryption_scheme_still_can_be_retrieved
291
+ # We want to ensure that if we have a file encrypted using an older scheme (v1 in this case) it still gets picked
292
+ # up by the service and decrypted correctly.
293
+ rng = Random.new(Minitest.seed)
294
+ encryption_key = Random.new(Minitest.seed).bytes(128)
295
+ scheme = ActiveStorageEncryption::EncryptedDiskService::V1Scheme.new(encryption_key)
296
+ key = rng.hex(32)
297
+
298
+ # We need to make the path for the file manually. The Rails DiskService does it like this -
299
+ # to make file enumeration faster:
300
+ # def folder_for(key)
301
+ # [ key[0..1], key[2..3] ].join("/")
302
+ # end
303
+ subfolder = [key[0..1], key[2..3]].join("/")
304
+ subfolder_path = File.join(@storage_dir, subfolder)
305
+ FileUtils.mkdir_p(subfolder_path)
306
+
307
+ file_path = File.join(subfolder_path, key + ".encrypted-v1")
308
+ plaintext = rng.bytes(2048)
309
+ File.open(file_path, "wb") do |f|
310
+ scheme.streaming_encrypt(from_plaintext_io: StringIO.new(plaintext), into_ciphertext_io: f)
311
+ end
312
+
313
+ # Now read it using the service. We should get the same plaintext back.
314
+ readback = @service.download(key, encryption_key:)
315
+ assert_equal plaintext.bytesize, readback.bytesize
316
+ assert_equal plaintext[32...64], readback[32...64]
317
+ end
318
+
319
+ def test_composes_objects
320
+ key1 = "key-1"
321
+ k1 = Random.bytes(68)
322
+ buf1 = generate_random_binary_string
323
+
324
+ key2 = "key-2"
325
+ k2 = Random.bytes(68)
326
+ buf2 = generate_random_binary_string
327
+
328
+ assert_nothing_raised do
329
+ @service.upload(key1, StringIO.new(buf1), encryption_key: k1)
330
+ @service.upload(key2, StringIO.new(buf2), encryption_key: k2)
331
+ end
332
+
333
+ composed_key = "key-3"
334
+ k3 = Random.bytes(68)
335
+ assert_nothing_raised do
336
+ @service.compose([key1, key2], composed_key, source_encryption_keys: [k1, k2], encryption_key: k3)
337
+ end
338
+
339
+ readback_composed_bytes = @service.download(composed_key, encryption_key: k3)
340
+ assert_equal Digest::SHA256.hexdigest(buf1 + buf2), Digest::SHA256.hexdigest(readback_composed_bytes)
341
+ end
342
+
343
+ def test_upload_then_failing_download_with_incorrect_key
344
+ rng = Random.new(Minitest.seed)
345
+ storage_blob_key = "key-1"
346
+ k1 = rng.bytes(68)
347
+ k2 = rng.bytes(68)
348
+ refute_equal k1, k2
349
+
350
+ plaintext_upload_bytes = generate_random_binary_string
351
+ assert_nothing_raised do
352
+ @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k1)
353
+ end
354
+ assert @service.exist?(storage_blob_key)
355
+
356
+ # Readback the bytes, but use the wrong IV and key
357
+ assert_raises(ActiveStorageEncryption::IncorrectEncryptionKey) do
358
+ @service.download(storage_blob_key, encryption_key: k2) { |bytes| readback_plaintext_bytes << bytes }
359
+ end
360
+
361
+ # Readback the bytes with the correct IV and key
362
+ readback_plaintext_bytes = (+"").b
363
+ @service.download(storage_blob_key, encryption_key: k1) { |bytes| readback_plaintext_bytes << bytes }
364
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes)
365
+ end
366
+
367
+ def test_non_encrypted_service_goes_through_normally
368
+ content = generate_random_binary_string
369
+ blob = assert_nothing_raised do
370
+ ActiveStorage::Blob.create_and_upload!(
371
+ io: StringIO.new(content),
372
+ filename: "random.text",
373
+ content_type: "text/plain",
374
+ service_name: "test" # use regular disk service
375
+ )
376
+ end
377
+ service = blob.service
378
+ downloaded_blob = assert_nothing_raised do
379
+ service.download(blob.key)
380
+ end
381
+ assert_equal content, downloaded_blob
382
+ end
383
+
384
+ def generate_random_binary_string(size = 17.kilobytes + 13)
385
+ Random.bytes(size)
386
+ end
387
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+
5
+ class ActiveStorageEncryption::EncryptedMirrorServiceTest < ActiveSupport::TestCase
6
+ include ActiveJob::TestHelper
7
+
8
+ def setup
9
+ @storage_dir = Dir.mktmpdir
10
+
11
+ @service1 = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir + "/primary-encrypted")
12
+ @service2 = ActiveStorage::Service::DiskService.new(root: @storage_dir + "/secondary-plain")
13
+ @service3 = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir + "/secondary-encrypted")
14
+
15
+ @service = ActiveStorageEncryption::EncryptedMirrorService.new(primary: @service1, mirrors: [@service2, @service3])
16
+ @service.name = "amazing_mirror_service" # Needed for service lookup
17
+ @previous_default_service = ActiveStorage::Blob.service
18
+
19
+ # The EncryptedDiskService generates URLs by itself, so it needs
20
+ # ActiveStorage::Current.url_options to be set
21
+ # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts.
22
+ # see https://stackoverflow.com/a/60573259/153886
23
+ ActiveStorage::Current.url_options = {
24
+ host: "www.example.com",
25
+ protocol: "https"
26
+ }
27
+ end
28
+
29
+ def teardown
30
+ FileUtils.rm_rf(@storage_dir)
31
+ ActiveStorage::Blob.service = @previous_default_service
32
+ end
33
+
34
+ def test_headers_for_direct_upload
35
+ key = "key-1"
36
+ k = Random.bytes(68)
37
+ md5 = Digest::MD5.base64digest("x")
38
+ headers = @service.headers_for_direct_upload(key, content_type: "image/jpeg", encryption_key: k, checksum: md5)
39
+ assert_equal headers["x-active-storage-encryption-key"], Base64.strict_encode64(k)
40
+ assert_equal headers["content-md5"], md5
41
+ end
42
+
43
+ def test_upload_with_checksum
44
+ # We need to test this to make sure the checksum gets verified after decryption
45
+ key = "key-1"
46
+ k = Random.bytes(68)
47
+ plaintext_upload_bytes = generate_random_binary_string
48
+
49
+ incorrect_base64_md5 = Digest::MD5.base64digest("Something completely different")
50
+ assert_raises(ActiveStorage::IntegrityError) do
51
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: incorrect_base64_md5)
52
+ end
53
+ refute @service.exist?(key)
54
+
55
+ correct_base64_md5 = Digest::MD5.base64digest(plaintext_upload_bytes)
56
+ assert_nothing_raised do
57
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: correct_base64_md5)
58
+ end
59
+ assert @service.exist?(key)
60
+ end
61
+
62
+ def test_uploads_to_primary_and_mirrors_to_secondaries
63
+ # So that the job can find our service
64
+ ActiveStorage::Blob.service = @service
65
+
66
+ key = "key-1"
67
+ k = Random.bytes(68)
68
+ plaintext_upload_bytes = Random.bytes(42)
69
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
70
+
71
+ assert @service.exist?(key)
72
+ assert @service1.exist?(key) # Primary
73
+ refute @service2.exist?(key)
74
+ refute @service3.exist?(key)
75
+
76
+ perform_enqueued_jobs
77
+
78
+ assert @service2.exist?(key)
79
+ assert @service3.exist?(key)
80
+ assert_equal plaintext_upload_bytes, @service2.download(key)
81
+ assert_equal plaintext_upload_bytes, @service3.download(key, encryption_key: k)
82
+ end
83
+
84
+ def test_generates_direct_upload_url_for_primary
85
+ key = "key-1"
86
+ k = Random.bytes(68)
87
+ plaintext_upload_bytes = generate_random_binary_string
88
+
89
+ ActiveStorage::Blob.service = @service # So that the controller can find it
90
+ b64md5 = Digest::MD5.base64digest(plaintext_upload_bytes)
91
+
92
+ url = @service.url_for_direct_upload(key, expires_in: 60.seconds, content_type: "binary/octet-stream", content_length: plaintext_upload_bytes.bytesize, checksum: b64md5, encryption_key: k, custom_metadata: {})
93
+ assert url.include?("/active-storage-encryption/blob/")
94
+ end
95
+
96
+ def passes_through_private_url_policy_from_primary
97
+ @service1.private_url_policy = :disable
98
+ assert_equal :disable, @service.private_url_policy
99
+
100
+ @service1.private_url_policy = :stream
101
+ assert_equal :stream, @service.private_url_policy
102
+ end
103
+
104
+ def test_does_not_accept_private_url_policy
105
+ assert_raises(ArgumentError) do
106
+ @service.private_url_policy = :stream
107
+ end
108
+ end
109
+
110
+ def test_get_without_headers_succeeds_if_service_permits
111
+ @service1.private_url_policy = :stream
112
+
113
+ key = "key-1"
114
+ k = Random.bytes(68)
115
+ plaintext_upload_bytes = generate_random_binary_string
116
+ @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
117
+
118
+ ActiveStorage::Blob.service = @service # So that the controller can find it
119
+
120
+ # ActiveStorage wraps the passed filename in a wrapper thingy
121
+ filename_with_sanitization = ActiveStorage::Filename.new("temp.bin")
122
+ url = @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds)
123
+ assert url.include?("/active-storage-encryption/blob/")
124
+ end
125
+
126
+ def test_upload_then_download_using_correct_key
127
+ storage_blob_key = "key-1"
128
+ k = Random.bytes(68)
129
+ plaintext_upload_bytes = generate_random_binary_string
130
+
131
+ assert_nothing_raised do
132
+ @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k)
133
+ end
134
+
135
+ assert @service.exist?(storage_blob_key)
136
+
137
+ encrypted_file_paths = Dir.glob(@storage_dir + "/**/*.encrypted-*").sort
138
+ readback_encrypted_bytes = File.binread(encrypted_file_paths.last)
139
+
140
+ # Make sure the output is, indeed, encrypted
141
+ refute_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_encrypted_bytes)
142
+
143
+ # Readback the entire file, decrypting it
144
+ readback_plaintext_bytes = (+"").b
145
+ @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes }
146
+ assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes)
147
+
148
+ # Test random access
149
+ from_offset = Random.rand(0..999)
150
+ chunk_size = Random.rand(0..1024)
151
+ range = (from_offset..(from_offset + chunk_size))
152
+ chunk_from_upload = plaintext_upload_bytes[range]
153
+ assert_equal chunk_from_upload, @service.download_chunk(storage_blob_key, range, encryption_key: k)
154
+ end
155
+
156
+ def generate_random_binary_string(size = 17.kilobytes + 13)
157
+ Random.bytes(size)
158
+ end
159
+ end