active_storage_encryption 0.1.0 → 0.2.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +12 -0
  3. data/.github/workflows/ci.yml +75 -0
  4. data/.gitignore +14 -0
  5. data/.ruby-version +1 -0
  6. data/.standard.yml +1 -0
  7. data/Appraisals +2 -0
  8. data/Gemfile +9 -0
  9. data/README.md +24 -3
  10. data/Rakefile +0 -2
  11. data/active_storage_encryption.gemspec +45 -0
  12. data/config/routes.rb +1 -1
  13. data/gemfiles/rails_7.gemfile +1 -0
  14. data/gemfiles/rails_7.gemfile.lock +10 -1
  15. data/gemfiles/rails_8.gemfile +1 -0
  16. data/gemfiles/rails_8.gemfile.lock +10 -1
  17. data/lib/active_storage_encryption/encrypted_blob_proxy_controller.rb +116 -0
  18. data/lib/active_storage_encryption/encrypted_blobs_controller.rb +0 -51
  19. data/lib/active_storage_encryption/encrypted_s3_service.rb +1 -0
  20. data/lib/active_storage_encryption/engine.rb +4 -0
  21. data/lib/active_storage_encryption/overrides.rb +1 -0
  22. data/lib/active_storage_encryption/private_url_policy.rb +4 -2
  23. data/lib/active_storage_encryption/version.rb +1 -1
  24. data/lib/active_storage_encryption.rb +1 -0
  25. data/lib/generators/add_encryption_key_to_active_storage_blobs.rb.erb +9 -0
  26. data/lib/generators/install_generator.rb +25 -0
  27. data/test/dummy/app/assets/images/.keep +0 -0
  28. data/test/dummy/app/controllers/concerns/.keep +0 -0
  29. data/test/dummy/app/models/concerns/.keep +0 -0
  30. data/test/dummy/lib/assets/.keep +0 -0
  31. data/test/dummy/log/.keep +0 -0
  32. data/test/fixtures/files/.keep +0 -0
  33. data/test/integration/.keep +0 -0
  34. data/test/integration/encrypted_blob_proxy_controller_test.rb +253 -0
  35. data/test/integration/encrypted_blobs_controller_test.rb +0 -130
  36. data/test/lib/encrypted_disk_service_test.rb +5 -119
  37. data/test/lib/encrypted_mirror_service_test.rb +1 -1
  38. data/test/lib/encrypted_s3_service_test.rb +5 -2
  39. metadata +35 -10
  40. data/test/dummy/log/test.log +0 -1022
  41. data/test/dummy/storage/test.sqlite3 +0 -0
  42. data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
  43. data/test/dummy/storage/yq/sv/yqsvw5a72b3fv719zq8a6yb7lv0j +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c79ba1ae2b68a8d4e76c9d02092e426a41ddd414a968a082121cab647c2e239
4
- data.tar.gz: ee60709d1843c4a814c52496a4fffcdec44b36bd3a48f18513fb0b56ba4a0c05
3
+ metadata.gz: 850bd7cbc71749f88f1a8e7c4305de38bfbb7c3641ee75864a34ce9f712af65a
4
+ data.tar.gz: 2ffa5b8c6abc2038395366138eb6f1d43dd4246f3d728e923a440805b7f53838
5
5
  SHA512:
6
- metadata.gz: 2d73fa7ea374c47f4fea531791db5962d469c8e1a95e9e7f9b0f64b9904e1b5948fd298b788b2a6c93a2e8beff0d58fde6ba24aa00c3c852b316232780a4ba4e
7
- data.tar.gz: 7a2427499a85bec52196dfbdf304193618febcf7c3ceb0eb0944a01452683dbbb2ff2304a716ae3694fb9f55f6683c337b4d19189f67ce034abe8a58e093a0f5
6
+ metadata.gz: 489bf8dbd01ee254354cf3dbcbedab9743f9f16f5b93c352cb234c8eef4f909c31916eef7187002eac8b0f66d476fd55d44cf31bd8320ea6fa275b36d62cb3b6
7
+ data.tar.gz: 746b6efed5b2e4819f280f511a1eb66882a257b173c6699b85a10606d1eac0210da92f6020c480e08de5e509a34f4eb7ff50f46c703609e5ed284ecd5cb0f23c
@@ -0,0 +1,12 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: bundler
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ open-pull-requests-limit: 10
8
+ - package-ecosystem: github-actions
9
+ directory: "/"
10
+ schedule:
11
+ interval: daily
12
+ open-pull-requests-limit: 10
@@ -0,0 +1,75 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [ main ]
7
+
8
+ jobs:
9
+ lint:
10
+ name: "Lint"
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Checkout code
14
+ uses: actions/checkout@v4
15
+
16
+ # Note: Appraisals for Rails 7 and Rails 8 differ in minimum Ruby version: 3.1.0+ vs 3.2.2+
17
+ # So the version of Ruby to use here is the version that is able to run all Appraisals
18
+ - name: Set up Ruby
19
+ uses: ruby/setup-ruby@v1
20
+ with:
21
+ ruby-version: 3.2.2
22
+ bundler-cache: true
23
+
24
+ - name: Lint code for consistent style
25
+ run: bundle exec standardrb
26
+
27
+ test_rails7:
28
+ name: "Tests (Rails 7)"
29
+ runs-on: ubuntu-latest
30
+ env:
31
+ BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_7.gemfile
32
+ steps:
33
+ - name: Install packages
34
+ run: sudo apt-get update && sudo apt-get install --no-install-recommends -y curl libjemalloc2 sqlite3
35
+
36
+ - name: Checkout code
37
+ uses: actions/checkout@v4
38
+
39
+ - name: Set up Ruby
40
+ uses: ruby/setup-ruby@v1
41
+ with:
42
+ ruby-version: 3.2.2
43
+ bundler-cache: true
44
+
45
+ - name: Run tests
46
+ env:
47
+ RAILS_ENV: test
48
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
49
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
50
+ run: bin/rails app:test
51
+
52
+ test_rails_8:
53
+ name: "Tests (Rails 8)"
54
+ runs-on: ubuntu-latest
55
+ env:
56
+ BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails_8.gemfile
57
+ steps:
58
+ - name: Install packages
59
+ run: sudo apt-get update && sudo apt-get install --no-install-recommends -y curl libjemalloc2 sqlite3
60
+
61
+ - name: Checkout code
62
+ uses: actions/checkout@v4
63
+
64
+ - name: Set up Ruby
65
+ uses: ruby/setup-ruby@v1
66
+ with:
67
+ ruby-version: 3.2.2
68
+ bundler-cache: true
69
+
70
+ - name: Run tests
71
+ env:
72
+ RAILS_ENV: test
73
+ AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
74
+ AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
75
+ run: bin/rails app:test
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /doc/
3
+ /log/*.log
4
+ /pkg/
5
+ /tmp/
6
+ /test/dummy/db/*.sqlite3
7
+ /test/dummy/db/*.sqlite3-*
8
+ /test/dummy/log/*.log
9
+ /test/dummy/storage/
10
+ /test/dummy/tmp/
11
+
12
+ # The Bundler lockfile should not be cached because its contents is arch-dependent
13
+ Gemfile.lock
14
+ .DS_Store
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.2.2
data/.standard.yml ADDED
@@ -0,0 +1 @@
1
+ ruby_version: 3.1
data/Appraisals CHANGED
@@ -1,7 +1,9 @@
1
1
  appraise "rails-7" do
2
2
  gem "rails", "< 8.0"
3
+ gem "stringio"
3
4
  end
4
5
 
5
6
  appraise "rails-8" do
6
7
  gem "rails", ">= 8.0"
8
+ gem "stringio"
7
9
  end
data/Gemfile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in active_storage_encryption.gemspec.
6
+ gemspec
7
+
8
+ # Start debugger with binding.b [https://github.com/ruby/debug]
9
+ # gem "debug", ">= 1.0.0"
data/README.md CHANGED
@@ -1,6 +1,4 @@
1
- ## What does this do?
2
-
3
- This library will enable the use of per-blob encryption keys with ActiveStorage. It enables file encryption with a separate encryption key generated for every `ActiveStorage::Blob`. It uses [CSEK](https://cloud.google.com/storage/docs/encryption/using-customer-supplied-keys) on Google Cloud, [SSE-C](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html#specifying-s3-c-encryption) on AWS, and [block_cipher_kit](https://rubygems.org/gems/block_cipher_kit) for files on disk to add a layer of encryption to every uploaded file. Every `Blob` gets its own, random encryption key.
1
+ This library enables use of per-blob encryption keys with ActiveStorage, with a separate encryption key for every `Blob`. To implement encryption, it enables the use of [CSEK](https://cloud.google.com/storage/docs/encryption/using-customer-supplied-keys) on Google Cloud, [SSE-C](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html#specifying-s3-c-encryption) on AWS, and [block_cipher_kit](https://rubygems.org/gems/block_cipher_kit) for files on disk.
4
2
 
5
3
  During streaming download, either the cloud provider or a Rails controller will decrypt the requested chunk of the file as it gets served to the client.
6
4
 
@@ -115,6 +113,8 @@ Implementation details:
115
113
  * `x-amz-server-side-encryption-customer-key`
116
114
  * `x-amz-server-side-encryption-customer-key-MD5`
117
115
 
116
+ While S3 allows the `x-amz-server-side-encryption-customer-key-MD5` to be added to the signed URL for PUT, the value of that header gets removed from the signature due to the process called "hoisting" - which occurs during the signing of the URL. So your client _may_ override the encryption key you give it forcibly, by replacing the `x-amz-server-side-encryption-customer-key` and `x-amz-server-side-encryption-customer-key-MD5`. This can produce Blobs encrypted with a key you do not have. If you want to exclude the possibility of this, you need to perform an integrity check on your uploads. The integrity check will fail if the encryption key has been overridden in this manner, and you can then destroy the Blob. This problem has been reported to AWS.
117
+
118
118
  ### EncryptedDiskSevice - Filesystem
119
119
 
120
120
  Can be used instead of the cloud services in development, or on the server if desired. The service will use AES-256-GCM encryption, with a way to switch to a different/more modern encryption scheme in the future.
@@ -185,10 +185,31 @@ When we stream through the controller, we encrypt the token instead of just sign
185
185
 
186
186
  ## Direct uploads
187
187
 
188
+ Our recommended setup is to have your encrypted ActiveStorage service as an additional service configuration in `service.yml`:
189
+
190
+ ```yaml
191
+ development:
192
+ public: true
193
+ service: Disk
194
+ root: <%= Rails.root.join("storage") %>
195
+
196
+ encrypted_disk:
197
+ service: EncryptedDisk
198
+ root: <%= Rails.root.join("storage", "encrypted") %>
199
+ private_url_policy: stream
200
+
201
+ ```
202
+
203
+ To upload into a named service that is non-default, you will need to use a different method for generating your presigned upload URL, as the standard Rails controller that creates the `Blob` records prior to upload does not allow you to set it. If you follow the [officlal Rails guide](https://guides.rubyonrails.org/active_storage_overview.html#direct-uploads) you will need to use a different URL helper for generating a URL to create blobs, which _does_ accommodate the service name. The URL you need to use is `create_encrypted_blob_direct_upload_url` (instead of `rails_direct_uploads_url`).
204
+
205
+ This is not going to be necessary once the corresponding [Rails issue](https://github.com/rails/rails/issues/38940) will get addressed.
206
+
188
207
  All supported services accept the encryption key as part of the headers for the PUT direct upload. The provided Service implementations will generate the correct headers for you. However, your upload client _must_ use the headers provided to you by the server, and not invent its own. The standard ActiveStorage JS bundles honor those headers - but if you use your own uploader you will need to ensure it honors and forwards the headers too.
189
208
 
190
209
  We also configure the services to generate _and_ require the `Content-MD5` header. The client doing the PUT will need to precompute the MD5 for the object before starting the PUT request or getting a PUT url (as the checksum gets signed by the cloud SDK or by our `EncryptedDiskService`). This is so that any data transmission errors can be intercepted early (we know of one case where a bug in Ruby, combined with a particular HTTP client library, led to bytes getting uploaded out of order).
191
210
 
211
+ Note that the encryption headers may require amending your CORS configuration - see the documentation per service regarding that.
212
+
192
213
  ## Security considerations / notes
193
214
 
194
215
  * It is imperative that you let the server generate the encryption key, and not generate one on the client. Where possible, we add the headers for the encryption key to the signed URL parameters, so client generated keys will deliberately _not_ function. Letting the client generate the keys can lead to key reuse (unintentional or - worse - intentional).
data/Rakefile CHANGED
@@ -7,8 +7,6 @@ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
7
7
  load "rails/tasks/engine.rake"
8
8
  load "rails/tasks/statistics.rake"
9
9
 
10
- require "bundler/gem_tasks"
11
-
12
10
  task :format do
13
11
  `bundle exec standardrb --fix`
14
12
  `bundle exec magic_frozen_string_literal .`
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/active_storage_encryption/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "active_storage_encryption"
7
+ spec.version = ActiveStorageEncryption::VERSION
8
+ spec.authors = ["Julik Tarkhanov", "Sebastian van Hesteren"]
9
+ spec.email = ["me@julik.nl"]
10
+ spec.homepage = "https://github.com/cheddar-me/active_storage_encryption"
11
+ spec.summary = "Customer-supplied encryption key support for ActiveStorage blobs."
12
+ spec.description = "Adds customer-supplied encryption keys to storage services."
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.1.0"
15
+
16
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the "allowed_push_host"
17
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
18
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
19
+
20
+ # The homepage link on rubygems.org only appears if you add homepage_uri. Just spec.homepage is not enough.
21
+ spec.metadata["homepage_uri"] = spec.homepage
22
+ spec.metadata["source_code_uri"] = spec.homepage
23
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
+
25
+ # Do not remove any files from the gemspec - tests are useful because people can read them
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ `git ls-files -z`.split("\x0")
28
+ end
29
+
30
+ spec.add_dependency "rails", ">= 7.2.2.1"
31
+ spec.add_dependency "block_cipher_kit", ">= 0.0.4"
32
+ spec.add_dependency "serve_byte_range", "~> 1.0"
33
+ spec.add_dependency "activestorage"
34
+
35
+ # Testing with cloud services
36
+ spec.add_development_dependency "aws-sdk-s3"
37
+ spec.add_development_dependency "net-http"
38
+
39
+ # Code formatting, linting and testing
40
+ spec.add_development_dependency "sqlite3"
41
+ spec.add_development_dependency "standard", ">= 1.35.1"
42
+ spec.add_development_dependency "appraisal"
43
+ spec.add_development_dependency "magic_frozen_string_literal"
44
+ spec.add_development_dependency "rake"
45
+ end
data/config/routes.rb CHANGED
@@ -2,6 +2,6 @@
2
2
 
3
3
  ActiveStorageEncryption::Engine.routes.draw do
4
4
  put "/blob/:token", to: "encrypted_blobs#update", as: "encrypted_blob_put"
5
- get "/blob/:token/*filename(.:format)", to: "encrypted_blobs#show", as: "encrypted_blob_streaming_get"
6
5
  post "/blob/direct-uploads", to: "encrypted_blobs#create_direct_upload", as: "create_encrypted_blob_direct_upload"
6
+ get "/blob/:token/*filename(.:format)", to: "encrypted_blob_proxy#show", as: "encrypted_blob_streaming_get"
7
7
  end
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "< 8.0"
6
+ gem "stringio"
6
7
 
7
8
  gemspec path: "../"
@@ -1,10 +1,11 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_storage_encryption (0.1.0)
4
+ active_storage_encryption (0.2.2)
5
5
  activestorage
6
6
  block_cipher_kit (>= 0.0.4)
7
7
  rails (>= 7.2.2.1)
8
+ serve_byte_range (~> 1.0)
8
9
 
9
10
  GEM
10
11
  remote: https://rubygems.org/
@@ -151,6 +152,8 @@ GEM
151
152
  net-smtp (0.5.1)
152
153
  net-protocol
153
154
  nio4r (2.7.4)
155
+ nokogiri (1.18.3-arm64-darwin)
156
+ racc (~> 1.4)
154
157
  nokogiri (1.18.3-x86_64-darwin)
155
158
  racc (~> 1.4)
156
159
  nokogiri (1.18.3-x86_64-linux-gnu)
@@ -227,6 +230,9 @@ GEM
227
230
  rubocop-ast (>= 1.31.1, < 2.0)
228
231
  ruby-progressbar (1.13.0)
229
232
  securerandom (0.4.1)
233
+ serve_byte_range (1.0.0)
234
+ rack (>= 1.0)
235
+ sqlite3 (2.6.0-arm64-darwin)
230
236
  sqlite3 (2.6.0-x86_64-darwin)
231
237
  sqlite3 (2.6.0-x86_64-linux-gnu)
232
238
  standard (1.45.0)
@@ -258,6 +264,8 @@ GEM
258
264
  zeitwerk (2.7.2)
259
265
 
260
266
  PLATFORMS
267
+ arm64-darwin-21
268
+ arm64-darwin-24
261
269
  x86_64-darwin
262
270
  x86_64-linux
263
271
 
@@ -271,6 +279,7 @@ DEPENDENCIES
271
279
  rake
272
280
  sqlite3
273
281
  standard (>= 1.35.1)
282
+ stringio
274
283
 
275
284
  BUNDLED WITH
276
285
  2.5.11
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", ">= 8.0"
6
+ gem "stringio"
6
7
 
7
8
  gemspec path: "../"
@@ -1,10 +1,11 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- active_storage_encryption (0.1.0)
4
+ active_storage_encryption (0.2.2)
5
5
  activestorage
6
6
  block_cipher_kit (>= 0.0.4)
7
7
  rails (>= 7.2.2.1)
8
+ serve_byte_range (~> 1.0)
8
9
 
9
10
  GEM
10
11
  remote: https://rubygems.org/
@@ -151,6 +152,8 @@ GEM
151
152
  net-smtp (0.5.1)
152
153
  net-protocol
153
154
  nio4r (2.7.4)
155
+ nokogiri (1.18.3-arm64-darwin)
156
+ racc (~> 1.4)
154
157
  nokogiri (1.18.3-x86_64-darwin)
155
158
  racc (~> 1.4)
156
159
  nokogiri (1.18.3-x86_64-linux-gnu)
@@ -227,6 +230,9 @@ GEM
227
230
  rubocop-ast (>= 1.31.1, < 2.0)
228
231
  ruby-progressbar (1.13.0)
229
232
  securerandom (0.4.1)
233
+ serve_byte_range (1.0.0)
234
+ rack (>= 1.0)
235
+ sqlite3 (2.6.0-arm64-darwin)
230
236
  sqlite3 (2.6.0-x86_64-darwin)
231
237
  sqlite3 (2.6.0-x86_64-linux-gnu)
232
238
  standard (1.45.0)
@@ -258,6 +264,8 @@ GEM
258
264
  zeitwerk (2.7.2)
259
265
 
260
266
  PLATFORMS
267
+ arm64-darwin-21
268
+ arm64-darwin-24
261
269
  x86_64-darwin
262
270
  x86_64-linux
263
271
 
@@ -271,6 +279,7 @@ DEPENDENCIES
271
279
  rake
272
280
  sqlite3
273
281
  standard (>= 1.35.1)
282
+ stringio
274
283
 
275
284
  BUNDLED WITH
276
285
  2.5.11
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "serve_byte_range"
4
+
5
+ # This controller is analogous to the ActiveStorage::ProxyController
6
+ class ActiveStorageEncryption::EncryptedBlobProxyController < ActionController::Base
7
+ include ActiveStorage::SetCurrent
8
+
9
+ class InvalidParams < StandardError
10
+ end
11
+
12
+ DEFAULT_BLOB_STREAMING_DISPOSITION = "inline"
13
+
14
+ self.etag_with_template_digest = false
15
+ skip_forgery_protection
16
+
17
+ # Streams the decrypted contents of an encrypted blob
18
+ def show
19
+ params = read_params_from_token_and_headers_for_get
20
+ service = lookup_service(params[:service_name])
21
+ raise InvalidParams, "#{service.name} does not allow private URLs" if service.private_url_policy == :disable
22
+
23
+ # Test the encryption key beforehand, so that the exception does not get raised when serving the actual body
24
+ service.download_chunk(params[:key], 0..0, encryption_key: params[:encryption_key])
25
+
26
+ stream_blob(service:,
27
+ key: params[:key],
28
+ encryption_key: params[:encryption_key],
29
+ blob_byte_size: params[:blob_byte_size],
30
+ filename: params[:filename],
31
+ disposition: params[:disposition] || DEFAULT_BLOB_STREAMING_DISPOSITION,
32
+ type: params[:content_type])
33
+ rescue ActiveStorage::FileNotFoundError
34
+ head :not_found
35
+ rescue InvalidParams, ActiveStorageEncryption::StreamingTokenInvalidOrExpired, ActiveSupport::MessageEncryptor::InvalidMessage, ActiveStorageEncryption::IncorrectEncryptionKey
36
+ head :forbidden
37
+ end
38
+
39
+ private
40
+
41
+ def read_params_from_token_and_headers_for_get
42
+ token_str = params.require(:token)
43
+
44
+ # The token params for GET / private_url download are encrypted, as they contain the object encryption key.
45
+ token_params = ActiveStorageEncryption.token_encryptor.decrypt_and_verify(token_str, purpose: :encrypted_get).symbolize_keys
46
+ encryption_key = Base64.decode64(token_params.fetch(:encryption_key))
47
+ service = lookup_service(token_params.fetch(:service_name))
48
+
49
+ # To be more like cloud services: verify presence of headers, if we were asked to (but this is optional)
50
+ if service.private_url_policy == :require_headers
51
+ b64_encryption_key = request.headers["x-active-storage-encryption-key"]
52
+ raise InvalidParams, "x-active-storage-encryption-key header is missing" if b64_encryption_key.blank?
53
+ raise InvalidParams, "Incorrect encryption key supplied via header" unless Rack::Utils.secure_compare(Base64.decode64(b64_encryption_key), encryption_key)
54
+ end
55
+
56
+ {
57
+ key: token_params.fetch(:key),
58
+ service_name: token_params.fetch(:service_name),
59
+ disposition: token_params.fetch(:disposition),
60
+ content_type: token_params.fetch(:content_type),
61
+ encryption_key: Base64.decode64(token_params.fetch(:encryption_key)),
62
+ blob_byte_size: token_params.fetch(:blob_byte_size)
63
+ }
64
+ end
65
+
66
+ def lookup_service(name)
67
+ service = ActiveStorage::Blob.services.fetch(name) { ActiveStorage::Blob.service }
68
+ raise InvalidParams, "No ActiveStorage default service defined and service #{name.inspect} was not found" unless service
69
+ raise InvalidParams, "#{service.name} is not providing file encryption" unless service.try(:encrypted?)
70
+ service
71
+ end
72
+
73
+ def stream_blob(service:, key:, blob_byte_size:, encryption_key:, filename:, disposition:, type:)
74
+ # The ActiveStorage::ProxyController buffers the entire response into memory
75
+ # when serving multipart byte ranges, which is extremely inefficient. We use our own thing
76
+ # which can actually stream from the Service directly, using byte ranges. This limits the
77
+ # amount of data buffered to 5 megabytes. There can be a better scheme with pagewise caching
78
+ # in tempfiles, but that's for later.
79
+ streaming_proc = ->(client_requested_range, response_io) {
80
+ chunk_size = 5.megabytes
81
+ client_requested_range.begin.step(client_requested_range.end, chunk_size) do |subrange_start|
82
+ chunk_end = subrange_start + chunk_size - 1
83
+ subrange_end = (chunk_end > client_requested_range.end) ? client_requested_range.end : chunk_end
84
+ range_on_service = subrange_start..subrange_end
85
+ response_io.write(service.download_chunk(key, range_on_service, encryption_key:))
86
+ end
87
+ }
88
+
89
+ # A few header things for streaming:
90
+ # 1. We need to ensure Rack::ETag does not suddenly start buffering us, for that either
91
+ # the ETag header or the Last-Modified header must be set. We set an ETag from the blob key,
92
+ # so nothing to do here.
93
+ # 2. Disable buffering for both nginx and Google Load Balancer, see
94
+ # https://cloud.google.com/appengine/docs/flexible/how-requests-are-handled?tab=python#x-accel-buffering
95
+ response.headers["X-Accel-Buffering"] = "no"
96
+ # 3. Make sure Rack::Deflater does not touch our response body either, see
97
+ # https://github.com/felixbuenemann/xlsxtream/issues/14#issuecomment-529569548
98
+ response.headers["Content-Encoding"] = "identity"
99
+
100
+ # Range requests use ETags to ensure that if a client goes to download a range of a resource
101
+ # it has already has some data of, it either gets the full resource - if it changed - or
102
+ # the bytes the client requested. An ActiveStorage blob never changes once it has been uploaded -
103
+ # it stays on the service "just as it was" until it gets deleted, so we can reliably use the key
104
+ # of the blob as the ETag.
105
+ blob_etag = key.inspect # Strong ETags must be quoted
106
+ status, headers, ranges_body = ServeByteRange.serve_ranges(request.env,
107
+ resource_size: blob_byte_size,
108
+ etag: blob_etag, # TODO
109
+ resource_content_type: type,
110
+ &streaming_proc)
111
+
112
+ response.status = status
113
+ headers.each { |(header, value)| response.headers[header] = value }
114
+ self.response_body = ranges_body
115
+ end
116
+ end
@@ -3,10 +3,6 @@
3
3
  class ActiveStorageEncryption::EncryptedBlobsController < ActionController::Base
4
4
  include ActiveStorage::SetCurrent
5
5
 
6
- # Below similar to ActiveStorage::Streaming but ActionController::Live is meh.
7
- include ActionController::DataStreaming
8
- include ActionController::Live
9
-
10
6
  class InvalidParams < StandardError
11
7
  end
12
8
 
@@ -32,24 +28,6 @@ class ActiveStorageEncryption::EncryptedBlobsController < ActionController::Base
32
28
  head :unprocessable_entity
33
29
  end
34
30
 
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
31
  # Creates a Blob record with a random encryption key and returns the details for PUTing it
54
32
  # This is only necessary because in Rails there is some disagreement regarding the service_name parameter.
55
33
  # See https://github.com/rails/rails/issues/38940
@@ -111,35 +89,6 @@ class ActiveStorageEncryption::EncryptedBlobsController < ActionController::Base
111
89
  }
112
90
  end
113
91
 
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
92
  def lookup_service(name)
144
93
  service = ActiveStorage::Blob.services.fetch(name) { ActiveStorage::Blob.service }
145
94
  raise InvalidParams, "#{service.name} is not providing file encryption" unless service.try(:encrypted?)
@@ -183,6 +183,7 @@ class ActiveStorageEncryption::EncryptedS3Service < ActiveStorage::Service::S3Se
183
183
  sse_options_for_presigned_url.delete(:sse_customer_key)
184
184
 
185
185
  options_for_super = options.merge(sse_options_for_presigned_url) # The "rest" kwargs for super are the `client_options`
186
+ options_for_super.delete(:blob_byte_size) # This is not a valid S3 option
186
187
  super(key, **options_for_super)
187
188
  end
188
189
  end
@@ -3,5 +3,9 @@
3
3
  module ActiveStorageEncryption
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace ActiveStorageEncryption
6
+
7
+ generators do
8
+ require "generators/install_generator"
9
+ end
6
10
  end
7
11
  end
@@ -138,6 +138,7 @@ module ActiveStorageEncryption
138
138
  key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
139
139
  encryption_key: encryption_key,
140
140
  content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition,
141
+ blob_byte_size: byte_size,
141
142
  **options
142
143
  )
143
144
  else
@@ -18,14 +18,15 @@ module ActiveStorageEncryption::PrivateUrlPolicy
18
18
  @private_url_policy
19
19
  end
20
20
 
21
- def private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:)
21
+ def private_url_for_streaming_via_controller(key, blob_byte_size:, expires_in:, filename:, content_type:, disposition:, encryption_key:)
22
22
  if private_url_policy == :disable
23
23
  raise ActiveStorageEncryption::StreamingDisabled, <<~EOS
24
24
  Requested a signed GET URL for #{key.inspect} on service #{name}. This service
25
25
  has disabled presigned URLs (private_url_policy: disable), you have to use `Blob#download` instead.
26
26
  EOS
27
27
  end
28
-
28
+ # This method requires the "blob_byte_size" because it is needed for HTTP ranges (you need to know the range of a resource),
29
+ # The ActiveStorage::ProxyController retrieves the blob from the DB for that, but we can embed it right in the token.
29
30
  content_disposition = content_disposition_with(type: disposition, filename: filename)
30
31
  verified_key_with_expiration = ActiveStorageEncryption.token_encryptor.encrypt_and_sign(
31
32
  {
@@ -34,6 +35,7 @@ module ActiveStorageEncryption::PrivateUrlPolicy
34
35
  encryption_key_sha256: Digest::SHA256.base64digest(encryption_key),
35
36
  content_type: content_type,
36
37
  service_name: name,
38
+ blob_byte_size: blob_byte_size,
37
39
  encryption_key: Base64.strict_encode64(encryption_key)
38
40
  },
39
41
  expires_in: expires_in,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveStorageEncryption
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -6,6 +6,7 @@ require "active_storage_encryption/engine"
6
6
  module ActiveStorageEncryption
7
7
  autoload :PrivateUrlPolicy, __dir__ + "/active_storage_encryption/private_url_policy.rb"
8
8
  autoload :EncryptedBlobsController, __dir__ + "/active_storage_encryption/encrypted_blobs_controller.rb"
9
+ autoload :EncryptedBlobProxyController, __dir__ + "/active_storage_encryption/encrypted_blob_proxy_controller.rb"
9
10
  autoload :EncryptedDiskService, __dir__ + "/active_storage_encryption/encrypted_disk_service.rb"
10
11
  autoload :EncryptedMirrorService, __dir__ + "/active_storage_encryption/encrypted_mirror_service.rb"
11
12
  autoload :EncryptedS3Service, __dir__ + "/active_storage_encryption/encrypted_s3_service.rb"
@@ -0,0 +1,9 @@
1
+ class AddEncryptionKeyToActiveStorageBlobs < ActiveRecord::Migration[7.2]
2
+ def change
3
+ # You _must_ use attribute encryption for this column. Rails uses base64 and JSON encoding
4
+ # for encrypted attributes, so they can be stored as a string. The "raw" encryption key
5
+ # that active_storage_encryption will generate and assign to the Blob is going to be
6
+ # binary, however.
7
+ add_column :active_storage_blobs, :encryption_key, :string, if_not_exists: true
8
+ end
9
+ end