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,276 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ active_storage_encryption (0.1.0)
5
+ activestorage
6
+ block_cipher_kit (>= 0.0.4)
7
+ rails (>= 7.2.2.1)
8
+
9
+ GEM
10
+ remote: https://rubygems.org/
11
+ specs:
12
+ actioncable (8.0.1)
13
+ actionpack (= 8.0.1)
14
+ activesupport (= 8.0.1)
15
+ nio4r (~> 2.0)
16
+ websocket-driver (>= 0.6.1)
17
+ zeitwerk (~> 2.6)
18
+ actionmailbox (8.0.1)
19
+ actionpack (= 8.0.1)
20
+ activejob (= 8.0.1)
21
+ activerecord (= 8.0.1)
22
+ activestorage (= 8.0.1)
23
+ activesupport (= 8.0.1)
24
+ mail (>= 2.8.0)
25
+ actionmailer (8.0.1)
26
+ actionpack (= 8.0.1)
27
+ actionview (= 8.0.1)
28
+ activejob (= 8.0.1)
29
+ activesupport (= 8.0.1)
30
+ mail (>= 2.8.0)
31
+ rails-dom-testing (~> 2.2)
32
+ actionpack (8.0.1)
33
+ actionview (= 8.0.1)
34
+ activesupport (= 8.0.1)
35
+ nokogiri (>= 1.8.5)
36
+ rack (>= 2.2.4)
37
+ rack-session (>= 1.0.1)
38
+ rack-test (>= 0.6.3)
39
+ rails-dom-testing (~> 2.2)
40
+ rails-html-sanitizer (~> 1.6)
41
+ useragent (~> 0.16)
42
+ actiontext (8.0.1)
43
+ actionpack (= 8.0.1)
44
+ activerecord (= 8.0.1)
45
+ activestorage (= 8.0.1)
46
+ activesupport (= 8.0.1)
47
+ globalid (>= 0.6.0)
48
+ nokogiri (>= 1.8.5)
49
+ actionview (8.0.1)
50
+ activesupport (= 8.0.1)
51
+ builder (~> 3.1)
52
+ erubi (~> 1.11)
53
+ rails-dom-testing (~> 2.2)
54
+ rails-html-sanitizer (~> 1.6)
55
+ activejob (8.0.1)
56
+ activesupport (= 8.0.1)
57
+ globalid (>= 0.3.6)
58
+ activemodel (8.0.1)
59
+ activesupport (= 8.0.1)
60
+ activerecord (8.0.1)
61
+ activemodel (= 8.0.1)
62
+ activesupport (= 8.0.1)
63
+ timeout (>= 0.4.0)
64
+ activestorage (8.0.1)
65
+ actionpack (= 8.0.1)
66
+ activejob (= 8.0.1)
67
+ activerecord (= 8.0.1)
68
+ activesupport (= 8.0.1)
69
+ marcel (~> 1.0)
70
+ activesupport (8.0.1)
71
+ base64
72
+ benchmark (>= 0.3)
73
+ bigdecimal
74
+ concurrent-ruby (~> 1.0, >= 1.3.1)
75
+ connection_pool (>= 2.2.5)
76
+ drb
77
+ i18n (>= 1.6, < 2)
78
+ logger (>= 1.4.2)
79
+ minitest (>= 5.1)
80
+ securerandom (>= 0.3)
81
+ tzinfo (~> 2.0, >= 2.0.5)
82
+ uri (>= 0.13.1)
83
+ appraisal (2.5.0)
84
+ bundler
85
+ rake
86
+ thor (>= 0.14.0)
87
+ ast (2.4.2)
88
+ aws-eventstream (1.3.1)
89
+ aws-partitions (1.1060.0)
90
+ aws-sdk-core (3.220.0)
91
+ aws-eventstream (~> 1, >= 1.3.0)
92
+ aws-partitions (~> 1, >= 1.992.0)
93
+ aws-sigv4 (~> 1.9)
94
+ base64
95
+ jmespath (~> 1, >= 1.6.1)
96
+ aws-sdk-kms (1.99.0)
97
+ aws-sdk-core (~> 3, >= 3.216.0)
98
+ aws-sigv4 (~> 1.5)
99
+ aws-sdk-s3 (1.182.0)
100
+ aws-sdk-core (~> 3, >= 3.216.0)
101
+ aws-sdk-kms (~> 1)
102
+ aws-sigv4 (~> 1.5)
103
+ aws-sigv4 (1.11.0)
104
+ aws-eventstream (~> 1, >= 1.0.2)
105
+ base64 (0.2.0)
106
+ benchmark (0.4.0)
107
+ bigdecimal (3.1.9)
108
+ block_cipher_kit (0.0.4)
109
+ builder (3.3.0)
110
+ concurrent-ruby (1.3.5)
111
+ connection_pool (2.5.0)
112
+ crass (1.0.6)
113
+ date (3.4.1)
114
+ drb (2.2.1)
115
+ erubi (1.13.1)
116
+ globalid (1.2.1)
117
+ activesupport (>= 6.1)
118
+ i18n (1.14.7)
119
+ concurrent-ruby (~> 1.0)
120
+ io-console (0.8.0)
121
+ irb (1.15.1)
122
+ pp (>= 0.6.0)
123
+ rdoc (>= 4.0.0)
124
+ reline (>= 0.4.2)
125
+ jmespath (1.6.2)
126
+ json (2.10.1)
127
+ language_server-protocol (3.17.0.4)
128
+ lint_roller (1.1.0)
129
+ logger (1.6.6)
130
+ loofah (2.24.0)
131
+ crass (~> 1.0.2)
132
+ nokogiri (>= 1.12.0)
133
+ magic_frozen_string_literal (1.2.0)
134
+ mail (2.8.1)
135
+ mini_mime (>= 0.1.1)
136
+ net-imap
137
+ net-pop
138
+ net-smtp
139
+ marcel (1.0.4)
140
+ mini_mime (1.1.5)
141
+ minitest (5.25.4)
142
+ net-http (0.6.0)
143
+ uri
144
+ net-imap (0.5.6)
145
+ date
146
+ net-protocol
147
+ net-pop (0.1.2)
148
+ net-protocol
149
+ net-protocol (0.2.2)
150
+ timeout
151
+ net-smtp (0.5.1)
152
+ net-protocol
153
+ nio4r (2.7.4)
154
+ nokogiri (1.18.3-x86_64-darwin)
155
+ racc (~> 1.4)
156
+ nokogiri (1.18.3-x86_64-linux-gnu)
157
+ racc (~> 1.4)
158
+ parallel (1.26.3)
159
+ parser (3.3.7.1)
160
+ ast (~> 2.4.1)
161
+ racc
162
+ pp (0.6.2)
163
+ prettyprint
164
+ prettyprint (0.2.0)
165
+ psych (5.2.3)
166
+ date
167
+ stringio
168
+ racc (1.8.1)
169
+ rack (3.1.11)
170
+ rack-session (2.1.0)
171
+ base64 (>= 0.1.0)
172
+ rack (>= 3.0.0)
173
+ rack-test (2.2.0)
174
+ rack (>= 1.3)
175
+ rackup (2.2.1)
176
+ rack (>= 3)
177
+ rails (8.0.1)
178
+ actioncable (= 8.0.1)
179
+ actionmailbox (= 8.0.1)
180
+ actionmailer (= 8.0.1)
181
+ actionpack (= 8.0.1)
182
+ actiontext (= 8.0.1)
183
+ actionview (= 8.0.1)
184
+ activejob (= 8.0.1)
185
+ activemodel (= 8.0.1)
186
+ activerecord (= 8.0.1)
187
+ activestorage (= 8.0.1)
188
+ activesupport (= 8.0.1)
189
+ bundler (>= 1.15.0)
190
+ railties (= 8.0.1)
191
+ rails-dom-testing (2.2.0)
192
+ activesupport (>= 5.0.0)
193
+ minitest
194
+ nokogiri (>= 1.6)
195
+ rails-html-sanitizer (1.6.2)
196
+ loofah (~> 2.21)
197
+ nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
198
+ railties (8.0.1)
199
+ actionpack (= 8.0.1)
200
+ activesupport (= 8.0.1)
201
+ irb (~> 1.13)
202
+ rackup (>= 1.0.0)
203
+ rake (>= 12.2)
204
+ thor (~> 1.0, >= 1.2.2)
205
+ zeitwerk (~> 2.6)
206
+ rainbow (3.1.1)
207
+ rake (13.2.1)
208
+ rdoc (6.12.0)
209
+ psych (>= 4.0.0)
210
+ regexp_parser (2.10.0)
211
+ reline (0.6.0)
212
+ io-console (~> 0.5)
213
+ rubocop (1.71.2)
214
+ json (~> 2.3)
215
+ language_server-protocol (>= 3.17.0)
216
+ parallel (~> 1.10)
217
+ parser (>= 3.3.0.2)
218
+ rainbow (>= 2.2.2, < 4.0)
219
+ regexp_parser (>= 2.9.3, < 3.0)
220
+ rubocop-ast (>= 1.38.0, < 2.0)
221
+ ruby-progressbar (~> 1.7)
222
+ unicode-display_width (>= 2.4.0, < 4.0)
223
+ rubocop-ast (1.38.1)
224
+ parser (>= 3.3.1.0)
225
+ rubocop-performance (1.23.1)
226
+ rubocop (>= 1.48.1, < 2.0)
227
+ rubocop-ast (>= 1.31.1, < 2.0)
228
+ ruby-progressbar (1.13.0)
229
+ securerandom (0.4.1)
230
+ sqlite3 (2.6.0-x86_64-darwin)
231
+ sqlite3 (2.6.0-x86_64-linux-gnu)
232
+ standard (1.45.0)
233
+ language_server-protocol (~> 3.17.0.2)
234
+ lint_roller (~> 1.0)
235
+ rubocop (~> 1.71.0)
236
+ standard-custom (~> 1.0.0)
237
+ standard-performance (~> 1.6)
238
+ standard-custom (1.0.2)
239
+ lint_roller (~> 1.0)
240
+ rubocop (~> 1.50)
241
+ standard-performance (1.6.0)
242
+ lint_roller (~> 1.1)
243
+ rubocop-performance (~> 1.23.0)
244
+ stringio (3.1.5)
245
+ thor (1.3.2)
246
+ timeout (0.4.3)
247
+ tzinfo (2.0.6)
248
+ concurrent-ruby (~> 1.0)
249
+ unicode-display_width (3.1.4)
250
+ unicode-emoji (~> 4.0, >= 4.0.4)
251
+ unicode-emoji (4.0.4)
252
+ uri (1.0.3)
253
+ useragent (0.16.11)
254
+ websocket-driver (0.7.7)
255
+ base64
256
+ websocket-extensions (>= 0.1.0)
257
+ websocket-extensions (0.1.5)
258
+ zeitwerk (2.7.2)
259
+
260
+ PLATFORMS
261
+ x86_64-darwin
262
+ x86_64-linux
263
+
264
+ DEPENDENCIES
265
+ active_storage_encryption!
266
+ appraisal
267
+ aws-sdk-s3
268
+ magic_frozen_string_literal
269
+ net-http
270
+ rails (>= 8.0)
271
+ rake
272
+ sqlite3
273
+ standard (>= 1.35.1)
274
+
275
+ BUNDLED WITH
276
+ 2.5.11
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Needed so that Rails can find our service definition. It will perform the following
4
+ # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will:
5
+ #
6
+ # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH
7
+ # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService"
8
+ require_relative "../../active_storage_encryption"
9
+ class ActiveStorage::Service::EncryptedDiskService < ActiveStorageEncryption::EncryptedDiskService
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Needed so that Rails can find our service definition. It will perform the following
4
+ # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will:
5
+ #
6
+ # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH
7
+ # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService"
8
+ require_relative "../../active_storage_encryption/active_storage_encryption"
9
+ class ActiveStorage::Service::EncryptedMirrorService < ActiveStorageEncryption::EncryptedMirrorService
10
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Needed so that Rails can find our service definition. It will perform the following
4
+ # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will:
5
+ #
6
+ # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH
7
+ # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService"
8
+ require_relative "../../active_storage_encryption"
9
+ class ActiveStorage::Service::EncryptedS3Service < ActiveStorageEncryption::EncryptedS3Service
10
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveStorageEncryption::EncryptedBlobsController < ActionController::Base
4
+ include ActiveStorage::SetCurrent
5
+
6
+ # Below similar to ActiveStorage::Streaming but ActionController::Live is meh.
7
+ include ActionController::DataStreaming
8
+ include ActionController::Live
9
+
10
+ class InvalidParams < StandardError
11
+ end
12
+
13
+ DEFAULT_BLOB_STREAMING_DISPOSITION = "inline"
14
+
15
+ self.etag_with_template_digest = false
16
+ skip_forgery_protection
17
+
18
+ # Accepts PUT requests for direct uploads to the EncryptedDiskService. It can actually accept
19
+ # uploads to any encrypted service, but for S3 and GCP the upload can be done to the cloud storage
20
+ # bucket directly.
21
+ def update
22
+ params = read_params_from_token_and_headers_for_put
23
+ service = lookup_service(params[:service_name])
24
+ key = params[:key]
25
+
26
+ service.upload(key, request.body,
27
+ content_type: params[:content_type],
28
+ content_length: params[:content_length],
29
+ checksum: params[:checksum],
30
+ encryption_key: params[:encryption_key])
31
+ rescue InvalidParams, ActiveStorageEncryption::IncorrectEncryptionKey, ActiveSupport::MessageVerifier::InvalidSignature, ActiveStorage::IntegrityError
32
+ head :unprocessable_entity
33
+ end
34
+
35
+ # Streams the decrypted contents of an encrypted blob
36
+ def show
37
+ params = read_params_from_token_and_headers_for_get
38
+ service = lookup_service(params[:service_name])
39
+ raise InvalidParams, "#{service.name} does not allow private URLs" if service.private_url_policy == :disable
40
+
41
+ key = params[:key]
42
+ encryption_key = params[:encryption_key]
43
+
44
+ send_stream(filename: params[:filename], disposition: params[:disposition] || DEFAULT_BLOB_STREAMING_DISPOSITION, type: params[:content_type]) do |stream|
45
+ service.download(key, encryption_key: encryption_key) do |chunk|
46
+ stream.write chunk
47
+ end
48
+ end
49
+ rescue InvalidParams, ActiveStorageEncryption::StreamingTokenInvalidOrExpired, ActiveSupport::MessageEncryptor::InvalidMessage, ActiveStorageEncryption::IncorrectEncryptionKey
50
+ head :forbidden
51
+ end
52
+
53
+ # Creates a Blob record with a random encryption key and returns the details for PUTing it
54
+ # This is only necessary because in Rails there is some disagreement regarding the service_name parameter.
55
+ # See https://github.com/rails/rails/issues/38940
56
+ # It does not require the service to support encryption. However, we mandate that the MD5 be provided upfront,
57
+ # so that it gets included into the signature
58
+ def create_direct_upload
59
+ blob_params = params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {})
60
+ unless blob_params[:checksum]
61
+ render(plain: "The `checksum' is required", status: :unprocessable_entity) and return
62
+ end
63
+
64
+ service = lookup_service(params.require(:service_name))
65
+ blob = ActiveStorage::Blob.create_before_direct_upload!(
66
+ **blob_params.to_h.symbolize_keys,
67
+ service_name: service.name
68
+ )
69
+ render json: direct_upload_json(blob)
70
+ end
71
+
72
+ private
73
+
74
+ def read_params_from_token_and_headers_for_put
75
+ token_str = params.require(:token)
76
+
77
+ # The token params for PUT / direct upload are signed but not encrypted - the encryption key
78
+ # is transmitted inside headers
79
+ token_params = ActiveStorage.verifier.verify(token_str, purpose: :encrypted_put).symbolize_keys
80
+
81
+ # Ensure we are getting sent exactly as many bytes as stated in the token
82
+ raise InvalidParams, "Request must specify body content-length" if request.headers["content-length"].blank?
83
+
84
+ actual_content_length = request.headers["content-length"].to_i
85
+ expected_content_length = token_params.fetch(:content_length)
86
+ if actual_content_length != expected_content_length
87
+ raise InvalidParams, "content-length mismatch, expecting upload of #{expected_content_length} bytes but sent #{actual_content_length}"
88
+ end
89
+
90
+ # Recover the encryption key from the headers (similar to how cloud storage services do it)
91
+ b64_encryption_key = request.headers["x-active-storage-encryption-key"]
92
+ raise InvalidParams, "x-active-storage-encryption-key header is missing" if b64_encryption_key.blank?
93
+ encryption_key = Base64.strict_decode64(b64_encryption_key)
94
+
95
+ # Verify the SHA of the encryption key
96
+ encryption_key_b64sha = Digest::SHA256.base64digest(encryption_key)
97
+ raise InvalidParams, "Incorrect checksum for the encryption key" unless Rack::Utils.secure_compare(encryption_key_b64sha, token_params.fetch(:encryption_key_sha256))
98
+
99
+ # Verify the Content-MD5
100
+ b64_md5_from_headers = request.headers["content-md5"]
101
+ raise InvalidParams, "Content-MD5 header is required" if b64_md5_from_headers.blank?
102
+ raise InvalidParams, "Content-MD5 differs from the known checksum" unless Rack::Utils.secure_compare(b64_md5_from_headers, token_params.fetch(:checksum))
103
+
104
+ {
105
+ key: token_params.fetch(:key),
106
+ encryption_key: encryption_key,
107
+ service_name: token_params.fetch(:service_name),
108
+ checksum: token_params[:checksum],
109
+ content_type: token_params.fetch(:content_type),
110
+ content_length: token_params.fetch(:content_length)
111
+ }
112
+ end
113
+
114
+ def read_params_from_token_and_headers_for_get
115
+ token_str = params.require(:token)
116
+
117
+ # The token params for GET / private_url download are encrypted, as they contain the object encryption key.
118
+ token_params = ActiveStorageEncryption.token_encryptor.decrypt_and_verify(token_str, purpose: :encrypted_get).symbolize_keys
119
+ encryption_key = Base64.decode64(token_params.fetch(:encryption_key))
120
+
121
+ service = lookup_service(token_params.fetch(:service_name))
122
+
123
+ # To be more like cloud services: verify presence of headers, if we were asked to (but this is optional)
124
+ if service.private_url_policy == :require_headers
125
+ b64_encryption_key = request.headers["x-active-storage-encryption-key"]
126
+ raise InvalidParams, "x-active-storage-encryption-key header is missing" if b64_encryption_key.blank?
127
+ raise InvalidParams, "Incorrect encryption key supplied via header" unless Rack::Utils.secure_compare(Base64.decode64(b64_encryption_key), encryption_key)
128
+ end
129
+
130
+ # Verify the SHA of the encryption key
131
+ encryption_key_b64sha = Digest::SHA256.base64digest(encryption_key)
132
+ raise InvalidParams, "Incorrect encryption key supplied via token" unless Rack::Utils.secure_compare(encryption_key_b64sha, token_params.fetch(:encryption_key_sha256))
133
+
134
+ {
135
+ key: token_params.fetch(:key),
136
+ encryption_key: encryption_key,
137
+ service_name: token_params.fetch(:service_name),
138
+ disposition: token_params.fetch(:disposition),
139
+ content_type: token_params.fetch(:content_type)
140
+ }
141
+ end
142
+
143
+ def lookup_service(name)
144
+ service = ActiveStorage::Blob.services.fetch(name) { ActiveStorage::Blob.service }
145
+ raise InvalidParams, "#{service.name} is not providing file encryption" unless service.try(:encrypted?)
146
+ service
147
+ end
148
+
149
+ def blob_args
150
+ params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :service_name, metadata: {}).to_h.symbolize_keys
151
+ end
152
+
153
+ def service_name_from_params_or_config
154
+ params[:service_name] || ActiveStorage::Blob.service.name # ? Rails.application.config.active_storage.service.name
155
+ end
156
+
157
+ def direct_upload_json(blob)
158
+ blob.as_json(root: false, methods: :signed_id).merge(direct_upload: {
159
+ url: blob.service_url_for_direct_upload,
160
+ headers: blob.service_headers_for_direct_upload
161
+ })
162
+ end
163
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveStorageEncryption::EncryptedDiskService::V1Scheme
4
+ def initialize(encryption_key)
5
+ @scheme = BlockCipherKit::AES256CFBCIVScheme.new(encryption_key)
6
+ @key_digest = Digest::SHA256.digest(encryption_key.byteslice(0, 16 + 32)) # In this scheme the IV is suffixed with the key
7
+ end
8
+
9
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
10
+ validate_key!(from_ciphertext_io)
11
+ @scheme.streaming_decrypt(from_ciphertext_io:, into_plaintext_io:, &blk)
12
+ end
13
+
14
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
15
+ into_ciphertext_io.write(@key_digest)
16
+ @scheme.streaming_encrypt(into_ciphertext_io:, from_plaintext_io:, &blk)
17
+ end
18
+
19
+ def decrypt_range(from_ciphertext_io:, range:)
20
+ validate_key!(from_ciphertext_io)
21
+ @scheme.decrypt_range(from_ciphertext_io:, range:)
22
+ end
23
+
24
+ def validate_key!(io)
25
+ key_digest_from_io = io.read(@key_digest.bytesize)
26
+ raise ActiveStorageEncryption::IncorrectEncryptionKey unless key_digest_from_io == @key_digest
27
+ end
28
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This scheme uses GCM encryption with CTR-based random access. The auth tag is stored
4
+ # at the end of the message. The message is prefixed by a SHA2 digest of the encryption key.
5
+ class ActiveStorageEncryption::EncryptedDiskService::V2Scheme
6
+ def initialize(encryption_key)
7
+ @scheme = BlockCipherKit::AES256GCMScheme.new(encryption_key)
8
+ @key_digest = Digest::SHA256.digest(encryption_key.byteslice(0, 32)) # In this scheme just the key is used
9
+ end
10
+
11
+ def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk)
12
+ check_key!(from_ciphertext_io)
13
+ @scheme.streaming_decrypt(from_ciphertext_io:, into_plaintext_io:, &blk)
14
+ end
15
+
16
+ def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk)
17
+ # See check_key! for rationale. We need a fast KVC (key validation code)
18
+ # to refuse the download if we know the key is incorrect.
19
+ into_ciphertext_io.write(@key_digest)
20
+ @scheme.streaming_encrypt(into_ciphertext_io:, from_plaintext_io:, &blk)
21
+ end
22
+
23
+ def decrypt_range(from_ciphertext_io:, range:)
24
+ check_key!(from_ciphertext_io)
25
+ @scheme.decrypt_range(from_ciphertext_io:, range:)
26
+ end
27
+
28
+ private def check_key!(io)
29
+ # We need a fast KCV (key check value) to refuse the download
30
+ # if we know the key is incorrect. We can't use the auth tag from GCM
31
+ # because it can only be computed if the entirety of the ciphertext has been read by the
32
+ # cipher - and we want random access. We could use a HMAC(encryption_key, auth_tag) at the
33
+ # tail of ciphertext to achieve the same, but that would require streaming_decrypt to seek inside
34
+ # the ciphertext IO to read the tail of the file - which we don't want to require.
35
+ #
36
+ # Besides, we want to not tie up server resources if we know
37
+ # that the furnished encryption key is incorrect. So: a KVC.
38
+ #
39
+ # We store the SHA2 value of the encryption key at the start of the ciphertext. We assume that the encryption
40
+ # key will be generated randomly and will be very high-entropy, so the only attack strategy for it is brute-force.
41
+ # Brute-force is keyspace / hashrate, as explained here: https://stackoverflow.com/questions/4764026/how-many-sha256-hashes-can-a-modern-computer-compute
42
+ # which, for our key of 32 bytes, gives us this calculation to find out the number of years to crack this SHA on
43
+ # a GeForce 2080Ti (based on https://hashcat.net/forum/thread-10185.html):
44
+ # ((256 ** 32) / (7173 * 1000 * 1000)) / 60 / 60 / 24 / 365
45
+ # which is
46
+ # 511883878862512581460395486615240253212171357229849212045742
47
+ # This is quite some years. So storing the digest of the key is reasonably safe.
48
+ key_digest_from_io = io.read(@key_digest.bytesize)
49
+ raise ActiveStorageEncryption::IncorrectEncryptionKey unless key_digest_from_io == @key_digest
50
+ end
51
+ end