active_storage_encryption 0.1.0 → 0.2.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 +4 -4
- data/README.md +22 -3
- data/Rakefile +0 -2
- data/config/routes.rb +1 -1
- data/gemfiles/rails_7.gemfile.lock +9 -1
- data/gemfiles/rails_8.gemfile.lock +9 -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/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/test/dummy/log/development.log +304 -0
- data/test/dummy/log/test.log +66758 -811
- data/test/dummy/storage/0a/mt/0amtaps713liftrtbxt9h998epz4 +0 -0
- data/test/dummy/storage/0b/93/0b93pygovuunam1a3ovzwmrbuw2x +0 -0
- data/test/dummy/storage/0m/3s/0m3s7r3nboblijr1jxlnvm3p3l4b +0 -0
- data/test/dummy/storage/0o/9s/0o9s4ctbpu757qh7ucyony0itek4 +0 -0
- data/test/dummy/storage/1e/q6/1eq646og0wazgfw7bwjqz2uem0g4 +0 -0
- data/test/dummy/storage/1n/o3/1no30cpwrm727bm6arvb7zxagdg1 +0 -0
- data/test/dummy/storage/1x/6w/1x6wsoq3pew17reztwax78lrr3hc +0 -0
- data/test/dummy/storage/28/de/28deswrv89c9f2tk7dz1l5uovd4r +0 -0
- data/test/dummy/storage/2h/sd/2hsd1mh20c6os2nzyoicfyymhwev +0 -0
- data/test/dummy/storage/2t/ni/2tnidhdk4c6cj0tnw3jiw88dgs4g +0 -0
- data/test/dummy/storage/2v/e0/2ve0555nluisy2el5cf4txzgae3j +0 -0
- data/test/dummy/storage/2z/c5/2zc5mj8g0o9l7mfnim0vs4v48xd6 +0 -0
- data/test/dummy/storage/34/xc/34xc9hk74dm9227d6mhgfcfxl4ue +0 -0
- data/test/dummy/storage/3z/0t/3z0tnve7ivrq0qyrvfhfzztjhjqs +0 -0
- data/test/dummy/storage/49/14/4914188q1dptpw4po91cp54f32bg +0 -0
- data/test/dummy/storage/4c/74/4c7412lfz0pm2ocg6u01h67bnsch +0 -0
- data/test/dummy/storage/52/qf/52qfbgjlf3gor3agsyrt09t19o55 +0 -0
- data/test/dummy/storage/57/go/57gok1uc4ebc3ugrjrje4lpe1ram +0 -0
- data/test/dummy/storage/5f/dv/5fdvt6tu1mkyajbz4hbxbw6fpt9w +0 -0
- data/test/dummy/storage/5x/b7/5xb7zzi66fi5f6yrn09pq4ogb9wo +0 -0
- data/test/dummy/storage/6m/vr/6mvr1fr5it125tm4vahjw6bv9wkz +0 -0
- data/test/dummy/storage/7b/hb/7bhbdxqn67lape1f49jqfktcei4n +0 -0
- data/test/dummy/storage/7n/4v/7n4vpm1q14y4qffc4jj78m036gtw +0 -0
- data/test/dummy/storage/7q/ku/7qkufbjwbbqwnf89uciosleixnew +0 -0
- data/test/dummy/storage/8l/5v/8l5vb4o02hx46s5qohfn5to945p3 +0 -0
- data/test/dummy/storage/8q/pu/8qpun3f8vzl7auxajvqyq8f48ngw +0 -0
- data/test/dummy/storage/8w/ag/8wag4ptmox207h7mobamk0tcebwx +0 -0
- data/test/dummy/storage/8w/v8/8wv8lrhsw4s2r6guh1csd3jd89ii +0 -0
- data/test/dummy/storage/9b/c6/9bc6wlpfnqdywpnxgeoin3w9b5ch +0 -0
- data/test/dummy/storage/9l/wk/9lwkt21k5iburdaitbwliw7krtwt +0 -0
- data/test/dummy/storage/9p/0v/9p0vgfw3l2854k7so3rp33rmyh7p +0 -0
- data/test/dummy/storage/9r/sy/9rsya3r6syft34qz24g1h4u4qq44 +0 -0
- data/test/dummy/storage/9s/es/9seslusr46xjf3mfzq10hkp13kc1 +0 -0
- data/test/dummy/storage/9t/nv/9tnvn5v52fkvurpgszf4gco78t5h +0 -0
- data/test/dummy/storage/9u/to/9utokgxyu6xyovandu7pjhogoaqp +0 -0
- data/test/dummy/storage/9w/a4/9wa4c20p4dvm1cd5thnv9f7ei13w +0 -0
- data/test/dummy/storage/at/kg/atkgs5gwz2xdv9lvqftsg6p7gcpu +0 -0
- data/test/dummy/storage/at/qo/atqomgf3rpb2f6e1tq1yn2xqzojv +0 -0
- data/test/dummy/storage/ba/lq/balqtije6kf82ht4lr70ajaae9kc +0 -0
- data/test/dummy/storage/bf/i1/bfi1ij9rygr6lkx1r0lhgi8o5smx +0 -0
- data/test/dummy/storage/bg/ye/bgyenotrv3aj6lk88edwv0c41pfj +0 -0
- data/test/dummy/storage/bu/xe/buxed4b1l78kcax53fa37awm9ywk +0 -0
- data/test/dummy/storage/d2/c1/d2c11nhikb474oq3q7so0xbhukvj +0 -0
- data/test/dummy/storage/development.sqlite3 +0 -0
- data/test/dummy/storage/dk/hy/dkhybxn2o27a8xgvfhsxpgqxa1zf +0 -0
- data/test/dummy/storage/e7/2n/e72nz5cz3wf6qvh4dw4qfnw6ucog +0 -0
- data/test/dummy/storage/eo/4q/eo4qn68m7al0ehhe0s23ycuzkjto +0 -0
- data/test/dummy/storage/ew/8s/ew8sejdsx8ddmrzkvfa37ebz1ts1 +0 -0
- data/test/dummy/storage/f8/q1/f8q1kpg2tou8ru0afj8d2gy6ym5p +0 -0
- data/test/dummy/storage/fr/55/fr558uhp1k93jzhb4butyi2ry51t +0 -0
- data/test/dummy/storage/g4/nh/g4nhx1zxbeiegqpgn8ppsl1yhm0t +0 -0
- data/test/dummy/storage/gg/r5/ggr51egxhqfh4w5eluzs47qceb76 +0 -0
- data/test/dummy/storage/gh/ua/ghuaagralqmjy8rkbwmuv3010lvs +0 -0
- data/test/dummy/storage/gx/uh/gxuhmf52ufli3m7ng8irp8ghxa1v +0 -0
- data/test/dummy/storage/h0/m1/h0m1emy251xus1d9qh6u25dzy18o +0 -0
- data/test/dummy/storage/hh/kc/hhkc2q8paptyvhw2m5hlwylhtfo5 +0 -0
- data/test/dummy/storage/hq/0q/hq0q04kr6qzrp0qaee8rehcp2tzx +0 -0
- data/test/dummy/storage/ii/g1/iig1ge3fsjitai4g2fkq4qt369wh +0 -0
- data/test/dummy/storage/io/f0/iof0mv7w8qjd6m826g52pzyxedet +0 -0
- data/test/dummy/storage/jk/2i/jk2ifmx6ac35ubk3esufnm6bn1m1 +0 -0
- data/test/dummy/storage/jw/4t/jw4trdeyfkw3j8z70xcnr9a7gqe5 +0 -0
- data/test/dummy/storage/ke/k2/kek24leksglm1rs2a78mfmot0p3s +0 -0
- data/test/dummy/storage/kh/6d/kh6doaxxwxiyes0yqz2dmmpajkzv +0 -0
- data/test/dummy/storage/kj/7n/kj7nookjhisagd80z8hlv3wn50am +0 -0
- data/test/dummy/storage/kq/lf/kqlf5udtrgrk4v55qodxyt6i68p8 +0 -0
- data/test/dummy/storage/ky/33/ky334jbo8eb08pj9qbe919iz91mh +0 -0
- data/test/dummy/storage/lt/zw/ltzw4lur2bheit1273ogpfzhv7j1 +0 -0
- data/test/dummy/storage/m2/ve/m2vejmyttn1ium81dopppom6vum6 +0 -0
- data/test/dummy/storage/m8/d4/m8d4r9iauedq8wlpvnx1f3ou0jwg +0 -0
- data/test/dummy/storage/m9/ee/m9eetioklzatyff94gq0vn1cga1n +0 -0
- data/test/dummy/storage/ma/v0/mav084zvmyoh1a8i7dcwqy2aaoi9 +0 -0
- data/test/dummy/storage/mg/pa/mgpauiu02i28j3poef65k3q0gfpw +0 -0
- data/test/dummy/storage/mm/8g/mm8gp5evncb1ol1lj2jlmra2ixij +0 -0
- data/test/dummy/storage/mm/d2/mmd21x8c1amgnidzw0wowiwug4g3 +0 -0
- data/test/dummy/storage/n2/qr/n2qro0y9heko9cwxlf10wiqiipsw +0 -0
- data/test/dummy/storage/n8/b7/n8b7b7qgu6jtw577dnn10jrrmszs +0 -0
- data/test/dummy/storage/n8/p2/n8p2ine0qqhphn09kqtxco4y7g0a +0 -0
- data/test/dummy/storage/nk/vh/nkvhgk7snpdy2ak6k02htxx9swp7 +0 -0
- data/test/dummy/storage/nn/s0/nns0nggo0x645ytco52adnsi4myp +0 -0
- data/test/dummy/storage/nu/kz/nukzl7cckkzh68i7kyjkm9mzw7c0 +0 -0
- data/test/dummy/storage/nv/8v/nv8vyoghcde1yr1bjpsw4327qt7s +0 -0
- data/test/dummy/storage/of/on/ofonhf1gs26k3dpj6o7b0ktzfowh +0 -0
- data/test/dummy/storage/pl/pf/plpfs59hvdoogj9gdweqta36csqn +0 -0
- data/test/dummy/storage/q5/g5/q5g55ekmscu10pzfw6q4syigt81g +0 -0
- data/test/dummy/storage/q5/kc/q5kcr9twyb9v4mh31pay0t7nkuwu +0 -0
- data/test/dummy/storage/qa/xd/qaxdngi74r52ahqg1pz8hjddeajc +0 -0
- data/test/dummy/storage/r7/5v/r75vadn34ak53vinylgnfdl1s8rt +0 -0
- data/test/dummy/storage/rj/rg/rjrghnyzyvxpkjw1a57mrloz72x1 +0 -0
- data/test/dummy/storage/se/h7/seh7eorfoanpp6de62pubv7kyu1a +0 -0
- data/test/dummy/storage/sj/i1/sji1oj12soz2fcjcoz0gejvzo8to +0 -0
- data/test/dummy/storage/sn/2r/sn2rku9thay4hbcbt926an69maku +0 -0
- data/test/dummy/storage/sw/jm/swjmbmxou3tnarcirxc6gdycxh91 +0 -0
- data/test/dummy/storage/sz/mq/szmqlydvpgqaw7p3v0wh444wtcif +0 -0
- data/test/dummy/storage/test.sqlite3 +0 -0
- data/test/dummy/storage/tg/by/tgbyrdvg94ivhhy2z59e8l9fod10 +0 -0
- data/test/dummy/storage/u5/vm/u5vmz08tuayqggd436et8fiaeml1 +0 -0
- data/test/dummy/storage/u6/pf/u6pf4yky0vbmvid3fa3lm4lre68g +0 -0
- data/test/dummy/storage/ub/ql/ubqlmlilt8ujgdpngcm1zae41kgy +0 -0
- data/test/dummy/storage/un/29/un29e9khqism72ag27ojccmn5sds +0 -0
- data/test/dummy/storage/ux/ns/uxnsvuk4rr1p67n1oq6tmraz0gaw +0 -0
- data/test/dummy/storage/v1/qo/v1qor0zxg3lctk9mbwyos3oag9gj +0 -0
- data/test/dummy/storage/v8/ok/v8okmd7374w1obna13a7anllx2vu +0 -0
- data/test/dummy/storage/vd/tf/vdtfmz2ctis3dr1r35do9bow2xj5 +0 -0
- data/test/dummy/storage/vo/dg/vodgq1inccnujjt3auber7tt8w8o +0 -0
- data/test/dummy/storage/vp/oe/vpoeiq00tf9pk0jcjlccomkju1zc +0 -0
- data/test/dummy/storage/vu/kg/vukgoj6qf96bhealui2yaeyn4n72 +0 -0
- data/test/dummy/storage/w7/2z/w72zoqu7v6v6jp0tpy671dcbvpow +0 -0
- data/test/dummy/storage/wa/3f/wa3fncsnozc6n4xfu32gw34geqcd +0 -0
- data/test/dummy/storage/wy/ix/wyixbqx3f6a4agb8bjhrtpblpaua +0 -0
- data/test/dummy/storage/xd/st/xdsttma3tqt7mex0vhp1vsm3dq16 +0 -0
- data/test/dummy/storage/xv/ej/xvejm2e064bnpunx3nmktaqs0x90 +0 -0
- data/test/dummy/storage/xx/py/xxpyyodssq2xmp57qrtvuw0wchwk +0 -0
- data/test/dummy/storage/xz/ik/xzikejc5sohi3zexa93s9xmg4jst +0 -0
- data/test/dummy/storage/y4/g8/y4g8teo86blcv0zysa2d2jawvk6i +0 -0
- data/test/dummy/storage/y9/58/y958xli6aoktx1ehuyjc1k8dcbzv +0 -0
- data/test/dummy/storage/yj/lw/yjlw8bf70iujb16deja8ae43rqbc +0 -0
- data/test/dummy/storage/z3/qy/z3qyb9avbucwhxa8909rpfued0y5 +0 -0
- data/test/dummy/storage/zr/wu/zrwudcg4kgo7r0jemszuzok8grqp +0 -0
- data/test/dummy/tmp/local_secret.txt +1 -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 +137 -4
- 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: 1f97954b59908255d0d2e09943e111ca33af803ca47d3a9af06a44e23064f725
|
4
|
+
data.tar.gz: 700a8909d6492c8a8a8633c24a15418428c5e740d15adeef2cfcb15dcf872ac9
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 5eef06c92277075e3b5b9c5b4421f6b8352956b48f29b442d70b777ce737326f0470a728903ae1a42994792deb2d026b30b7497ea01c9ed723965e2e03c0d0d9
|
7
|
+
data.tar.gz: d94575af669034bccf27f41de435c44219f247d71c8d19e3c24d27e0af5301e55e53afccd141e1c5047921880836380aa414f628f4602cafbce82831ceb35d5a
|
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
|
|
@@ -185,10 +183,31 @@ When we stream through the controller, we encrypt the token instead of just sign
|
|
185
183
|
|
186
184
|
## Direct uploads
|
187
185
|
|
186
|
+
Our recommended setup is to have your encrypted ActiveStorage service as an additional service configuration in `service.yml`:
|
187
|
+
|
188
|
+
```yaml
|
189
|
+
development:
|
190
|
+
public: true
|
191
|
+
service: Disk
|
192
|
+
root: <%= Rails.root.join("storage") %>
|
193
|
+
|
194
|
+
encrypted_disk:
|
195
|
+
service: EncryptedDisk
|
196
|
+
root: <%= Rails.root.join("storage", "encrypted") %>
|
197
|
+
private_url_policy: stream
|
198
|
+
|
199
|
+
```
|
200
|
+
|
201
|
+
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`).
|
202
|
+
|
203
|
+
This is not going to be necessary once the corresponding [Rails issue](https://github.com/rails/rails/issues/38940) will get addressed.
|
204
|
+
|
188
205
|
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
206
|
|
190
207
|
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
208
|
|
209
|
+
Note that the encryption headers may require amending your CORS configuration - see the documentation per service regarding that.
|
210
|
+
|
192
211
|
## Security considerations / notes
|
193
212
|
|
194
213
|
* 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 .`
|
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
|
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
active_storage_encryption (0.
|
4
|
+
active_storage_encryption (0.2.0)
|
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
|
|
@@ -1,10 +1,11 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ..
|
3
3
|
specs:
|
4
|
-
active_storage_encryption (0.
|
4
|
+
active_storage_encryption (0.2.0)
|
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
|
|
@@ -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"
|