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.
- checksums.yaml +7 -0
- data/Appraisals +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +236 -0
- data/Rakefile +17 -0
- data/bin/rails +26 -0
- data/bin/rubocop +8 -0
- data/config/initializers/active_storage_encryption.rb +9 -0
- data/config/routes.rb +7 -0
- data/gemfiles/rails_7.gemfile +7 -0
- data/gemfiles/rails_7.gemfile.lock +276 -0
- data/gemfiles/rails_8.gemfile +7 -0
- data/gemfiles/rails_8.gemfile.lock +276 -0
- data/lib/active_storage/service/encrypted_disk_service.rb +10 -0
- data/lib/active_storage/service/encrypted_mirror_service.rb +10 -0
- data/lib/active_storage/service/encrypted_s3_service.rb +10 -0
- data/lib/active_storage_encryption/encrypted_blobs_controller.rb +163 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb +28 -0
- data/lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb +51 -0
- data/lib/active_storage_encryption/encrypted_disk_service.rb +186 -0
- data/lib/active_storage_encryption/encrypted_mirror_service.rb +76 -0
- data/lib/active_storage_encryption/encrypted_s3_service.rb +236 -0
- data/lib/active_storage_encryption/engine.rb +7 -0
- data/lib/active_storage_encryption/overrides.rb +201 -0
- data/lib/active_storage_encryption/private_url_policy.rb +53 -0
- data/lib/active_storage_encryption/resumable_gcs_upload.rb +194 -0
- data/lib/active_storage_encryption/version.rb +5 -0
- data/lib/active_storage_encryption.rb +79 -0
- data/lib/tasks/active_storage_encryption_tasks.rake +6 -0
- data/test/active_storage_encryption_test.rb +9 -0
- data/test/dummy/Rakefile +8 -0
- data/test/dummy/app/assets/stylesheets/application.css +1 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/helpers/application_helper.rb +4 -0
- data/test/dummy/app/models/application_record.rb +5 -0
- data/test/dummy/app/views/layouts/application.html.erb +22 -0
- data/test/dummy/app/views/pwa/manifest.json.erb +22 -0
- data/test/dummy/app/views/pwa/service-worker.js +26 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +37 -0
- data/test/dummy/config/application.rb +43 -0
- data/test/dummy/config/boot.rb +7 -0
- data/test/dummy/config/credentials.yml.enc +1 -0
- data/test/dummy/config/database.yml +32 -0
- data/test/dummy/config/environment.rb +7 -0
- data/test/dummy/config/environments/development.rb +59 -0
- data/test/dummy/config/environments/production.rb +81 -0
- data/test/dummy/config/environments/test.rb +53 -0
- data/test/dummy/config/initializers/content_security_policy.rb +27 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +10 -0
- data/test/dummy/config/initializers/inflections.rb +18 -0
- data/test/dummy/config/initializers/permissions_policy.rb +15 -0
- data/test/dummy/config/locales/en.yml +31 -0
- data/test/dummy/config/master.key +1 -0
- data/test/dummy/config/puma.rb +36 -0
- data/test/dummy/config/routes.rb +5 -0
- data/test/dummy/config/storage.yml +21 -0
- data/test/dummy/config.ru +8 -0
- data/test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb +60 -0
- data/test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb +7 -0
- data/test/dummy/db/schema.rb +47 -0
- data/test/dummy/log/test.log +1022 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/406-unsupported-browser.html +66 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/icon.png +0 -0
- data/test/dummy/public/icon.svg +3 -0
- data/test/dummy/storage/test.sqlite3 +0 -0
- data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
- data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
- data/test/integration/encrypted_blobs_controller_test.rb +400 -0
- data/test/lib/encrypted_disk_service_test.rb +387 -0
- data/test/lib/encrypted_mirror_service_test.rb +159 -0
- data/test/lib/encrypted_s3_service_test.rb +293 -0
- data/test/test_helper.rb +19 -0
- 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
|