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.
- checksums.yaml +4 -4
- data/.github/dependabot.yml +12 -0
- data/.github/workflows/ci.yml +75 -0
- data/.gitignore +14 -0
- data/.ruby-version +1 -0
- data/.standard.yml +1 -0
- data/Appraisals +2 -0
- data/Gemfile +9 -0
- data/README.md +24 -3
- data/Rakefile +0 -2
- data/active_storage_encryption.gemspec +45 -0
- data/config/routes.rb +1 -1
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +10 -1
- data/gemfiles/rails_8.gemfile +1 -0
- data/gemfiles/rails_8.gemfile.lock +10 -1
- data/lib/active_storage_encryption/encrypted_blob_proxy_controller.rb +116 -0
- data/lib/active_storage_encryption/encrypted_blobs_controller.rb +0 -51
- data/lib/active_storage_encryption/encrypted_s3_service.rb +1 -0
- data/lib/active_storage_encryption/engine.rb +4 -0
- data/lib/active_storage_encryption/overrides.rb +1 -0
- data/lib/active_storage_encryption/private_url_policy.rb +4 -2
- data/lib/active_storage_encryption/version.rb +1 -1
- data/lib/active_storage_encryption.rb +1 -0
- data/lib/generators/add_encryption_key_to_active_storage_blobs.rb.erb +9 -0
- data/lib/generators/install_generator.rb +25 -0
- data/test/dummy/app/assets/images/.keep +0 -0
- data/test/dummy/app/controllers/concerns/.keep +0 -0
- data/test/dummy/app/models/concerns/.keep +0 -0
- data/test/dummy/lib/assets/.keep +0 -0
- data/test/dummy/log/.keep +0 -0
- data/test/fixtures/files/.keep +0 -0
- data/test/integration/.keep +0 -0
- data/test/integration/encrypted_blob_proxy_controller_test.rb +253 -0
- data/test/integration/encrypted_blobs_controller_test.rb +0 -130
- data/test/lib/encrypted_disk_service_test.rb +5 -119
- data/test/lib/encrypted_mirror_service_test.rb +1 -1
- data/test/lib/encrypted_s3_service_test.rb +5 -2
- metadata +35 -10
- data/test/dummy/log/test.log +0 -1022
- 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
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 850bd7cbc71749f88f1a8e7c4305de38bfbb7c3641ee75864a34ce9f712af65a
|
4
|
+
data.tar.gz: 2ffa5b8c6abc2038395366138eb6f1d43dd4246f3d728e923a440805b7f53838
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 489bf8dbd01ee254354cf3dbcbedab9743f9f16f5b93c352cb234c8eef4f909c31916eef7187002eac8b0f66d476fd55d44cf31bd8320ea6fa275b36d62cb3b6
|
7
|
+
data.tar.gz: 746b6efed5b2e4819f280f511a1eb66882a257b173c6699b85a10606d1eac0210da92f6020c480e08de5e509a34f4eb7ff50f46c703609e5ed284ecd5cb0f23c
|
@@ -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
data/Gemfile
ADDED
data/README.md
CHANGED
@@ -1,6 +1,4 @@
|
|
1
|
-
|
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
|
data/gemfiles/rails_7.gemfile
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
active_storage_encryption (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
|
data/gemfiles/rails_8.gemfile
CHANGED
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
active_storage_encryption (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
|
@@ -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,
|
@@ -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
|