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.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +22 -3
  3. data/Rakefile +0 -2
  4. data/config/routes.rb +1 -1
  5. data/gemfiles/rails_7.gemfile.lock +9 -1
  6. data/gemfiles/rails_8.gemfile.lock +9 -1
  7. data/lib/active_storage_encryption/encrypted_blob_proxy_controller.rb +116 -0
  8. data/lib/active_storage_encryption/encrypted_blobs_controller.rb +0 -51
  9. data/lib/active_storage_encryption/encrypted_s3_service.rb +1 -0
  10. data/lib/active_storage_encryption/overrides.rb +1 -0
  11. data/lib/active_storage_encryption/private_url_policy.rb +4 -2
  12. data/lib/active_storage_encryption/version.rb +1 -1
  13. data/lib/active_storage_encryption.rb +1 -0
  14. data/test/dummy/log/development.log +304 -0
  15. data/test/dummy/log/test.log +66758 -811
  16. data/test/dummy/storage/0a/mt/0amtaps713liftrtbxt9h998epz4 +0 -0
  17. data/test/dummy/storage/0b/93/0b93pygovuunam1a3ovzwmrbuw2x +0 -0
  18. data/test/dummy/storage/0m/3s/0m3s7r3nboblijr1jxlnvm3p3l4b +0 -0
  19. data/test/dummy/storage/0o/9s/0o9s4ctbpu757qh7ucyony0itek4 +0 -0
  20. data/test/dummy/storage/1e/q6/1eq646og0wazgfw7bwjqz2uem0g4 +0 -0
  21. data/test/dummy/storage/1n/o3/1no30cpwrm727bm6arvb7zxagdg1 +0 -0
  22. data/test/dummy/storage/1x/6w/1x6wsoq3pew17reztwax78lrr3hc +0 -0
  23. data/test/dummy/storage/28/de/28deswrv89c9f2tk7dz1l5uovd4r +0 -0
  24. data/test/dummy/storage/2h/sd/2hsd1mh20c6os2nzyoicfyymhwev +0 -0
  25. data/test/dummy/storage/2t/ni/2tnidhdk4c6cj0tnw3jiw88dgs4g +0 -0
  26. data/test/dummy/storage/2v/e0/2ve0555nluisy2el5cf4txzgae3j +0 -0
  27. data/test/dummy/storage/2z/c5/2zc5mj8g0o9l7mfnim0vs4v48xd6 +0 -0
  28. data/test/dummy/storage/34/xc/34xc9hk74dm9227d6mhgfcfxl4ue +0 -0
  29. data/test/dummy/storage/3z/0t/3z0tnve7ivrq0qyrvfhfzztjhjqs +0 -0
  30. data/test/dummy/storage/49/14/4914188q1dptpw4po91cp54f32bg +0 -0
  31. data/test/dummy/storage/4c/74/4c7412lfz0pm2ocg6u01h67bnsch +0 -0
  32. data/test/dummy/storage/52/qf/52qfbgjlf3gor3agsyrt09t19o55 +0 -0
  33. data/test/dummy/storage/57/go/57gok1uc4ebc3ugrjrje4lpe1ram +0 -0
  34. data/test/dummy/storage/5f/dv/5fdvt6tu1mkyajbz4hbxbw6fpt9w +0 -0
  35. data/test/dummy/storage/5x/b7/5xb7zzi66fi5f6yrn09pq4ogb9wo +0 -0
  36. data/test/dummy/storage/6m/vr/6mvr1fr5it125tm4vahjw6bv9wkz +0 -0
  37. data/test/dummy/storage/7b/hb/7bhbdxqn67lape1f49jqfktcei4n +0 -0
  38. data/test/dummy/storage/7n/4v/7n4vpm1q14y4qffc4jj78m036gtw +0 -0
  39. data/test/dummy/storage/7q/ku/7qkufbjwbbqwnf89uciosleixnew +0 -0
  40. data/test/dummy/storage/8l/5v/8l5vb4o02hx46s5qohfn5to945p3 +0 -0
  41. data/test/dummy/storage/8q/pu/8qpun3f8vzl7auxajvqyq8f48ngw +0 -0
  42. data/test/dummy/storage/8w/ag/8wag4ptmox207h7mobamk0tcebwx +0 -0
  43. data/test/dummy/storage/8w/v8/8wv8lrhsw4s2r6guh1csd3jd89ii +0 -0
  44. data/test/dummy/storage/9b/c6/9bc6wlpfnqdywpnxgeoin3w9b5ch +0 -0
  45. data/test/dummy/storage/9l/wk/9lwkt21k5iburdaitbwliw7krtwt +0 -0
  46. data/test/dummy/storage/9p/0v/9p0vgfw3l2854k7so3rp33rmyh7p +0 -0
  47. data/test/dummy/storage/9r/sy/9rsya3r6syft34qz24g1h4u4qq44 +0 -0
  48. data/test/dummy/storage/9s/es/9seslusr46xjf3mfzq10hkp13kc1 +0 -0
  49. data/test/dummy/storage/9t/nv/9tnvn5v52fkvurpgszf4gco78t5h +0 -0
  50. data/test/dummy/storage/9u/to/9utokgxyu6xyovandu7pjhogoaqp +0 -0
  51. data/test/dummy/storage/9w/a4/9wa4c20p4dvm1cd5thnv9f7ei13w +0 -0
  52. data/test/dummy/storage/at/kg/atkgs5gwz2xdv9lvqftsg6p7gcpu +0 -0
  53. data/test/dummy/storage/at/qo/atqomgf3rpb2f6e1tq1yn2xqzojv +0 -0
  54. data/test/dummy/storage/ba/lq/balqtije6kf82ht4lr70ajaae9kc +0 -0
  55. data/test/dummy/storage/bf/i1/bfi1ij9rygr6lkx1r0lhgi8o5smx +0 -0
  56. data/test/dummy/storage/bg/ye/bgyenotrv3aj6lk88edwv0c41pfj +0 -0
  57. data/test/dummy/storage/bu/xe/buxed4b1l78kcax53fa37awm9ywk +0 -0
  58. data/test/dummy/storage/d2/c1/d2c11nhikb474oq3q7so0xbhukvj +0 -0
  59. data/test/dummy/storage/development.sqlite3 +0 -0
  60. data/test/dummy/storage/dk/hy/dkhybxn2o27a8xgvfhsxpgqxa1zf +0 -0
  61. data/test/dummy/storage/e7/2n/e72nz5cz3wf6qvh4dw4qfnw6ucog +0 -0
  62. data/test/dummy/storage/eo/4q/eo4qn68m7al0ehhe0s23ycuzkjto +0 -0
  63. data/test/dummy/storage/ew/8s/ew8sejdsx8ddmrzkvfa37ebz1ts1 +0 -0
  64. data/test/dummy/storage/f8/q1/f8q1kpg2tou8ru0afj8d2gy6ym5p +0 -0
  65. data/test/dummy/storage/fr/55/fr558uhp1k93jzhb4butyi2ry51t +0 -0
  66. data/test/dummy/storage/g4/nh/g4nhx1zxbeiegqpgn8ppsl1yhm0t +0 -0
  67. data/test/dummy/storage/gg/r5/ggr51egxhqfh4w5eluzs47qceb76 +0 -0
  68. data/test/dummy/storage/gh/ua/ghuaagralqmjy8rkbwmuv3010lvs +0 -0
  69. data/test/dummy/storage/gx/uh/gxuhmf52ufli3m7ng8irp8ghxa1v +0 -0
  70. data/test/dummy/storage/h0/m1/h0m1emy251xus1d9qh6u25dzy18o +0 -0
  71. data/test/dummy/storage/hh/kc/hhkc2q8paptyvhw2m5hlwylhtfo5 +0 -0
  72. data/test/dummy/storage/hq/0q/hq0q04kr6qzrp0qaee8rehcp2tzx +0 -0
  73. data/test/dummy/storage/ii/g1/iig1ge3fsjitai4g2fkq4qt369wh +0 -0
  74. data/test/dummy/storage/io/f0/iof0mv7w8qjd6m826g52pzyxedet +0 -0
  75. data/test/dummy/storage/jk/2i/jk2ifmx6ac35ubk3esufnm6bn1m1 +0 -0
  76. data/test/dummy/storage/jw/4t/jw4trdeyfkw3j8z70xcnr9a7gqe5 +0 -0
  77. data/test/dummy/storage/ke/k2/kek24leksglm1rs2a78mfmot0p3s +0 -0
  78. data/test/dummy/storage/kh/6d/kh6doaxxwxiyes0yqz2dmmpajkzv +0 -0
  79. data/test/dummy/storage/kj/7n/kj7nookjhisagd80z8hlv3wn50am +0 -0
  80. data/test/dummy/storage/kq/lf/kqlf5udtrgrk4v55qodxyt6i68p8 +0 -0
  81. data/test/dummy/storage/ky/33/ky334jbo8eb08pj9qbe919iz91mh +0 -0
  82. data/test/dummy/storage/lt/zw/ltzw4lur2bheit1273ogpfzhv7j1 +0 -0
  83. data/test/dummy/storage/m2/ve/m2vejmyttn1ium81dopppom6vum6 +0 -0
  84. data/test/dummy/storage/m8/d4/m8d4r9iauedq8wlpvnx1f3ou0jwg +0 -0
  85. data/test/dummy/storage/m9/ee/m9eetioklzatyff94gq0vn1cga1n +0 -0
  86. data/test/dummy/storage/ma/v0/mav084zvmyoh1a8i7dcwqy2aaoi9 +0 -0
  87. data/test/dummy/storage/mg/pa/mgpauiu02i28j3poef65k3q0gfpw +0 -0
  88. data/test/dummy/storage/mm/8g/mm8gp5evncb1ol1lj2jlmra2ixij +0 -0
  89. data/test/dummy/storage/mm/d2/mmd21x8c1amgnidzw0wowiwug4g3 +0 -0
  90. data/test/dummy/storage/n2/qr/n2qro0y9heko9cwxlf10wiqiipsw +0 -0
  91. data/test/dummy/storage/n8/b7/n8b7b7qgu6jtw577dnn10jrrmszs +0 -0
  92. data/test/dummy/storage/n8/p2/n8p2ine0qqhphn09kqtxco4y7g0a +0 -0
  93. data/test/dummy/storage/nk/vh/nkvhgk7snpdy2ak6k02htxx9swp7 +0 -0
  94. data/test/dummy/storage/nn/s0/nns0nggo0x645ytco52adnsi4myp +0 -0
  95. data/test/dummy/storage/nu/kz/nukzl7cckkzh68i7kyjkm9mzw7c0 +0 -0
  96. data/test/dummy/storage/nv/8v/nv8vyoghcde1yr1bjpsw4327qt7s +0 -0
  97. data/test/dummy/storage/of/on/ofonhf1gs26k3dpj6o7b0ktzfowh +0 -0
  98. data/test/dummy/storage/pl/pf/plpfs59hvdoogj9gdweqta36csqn +0 -0
  99. data/test/dummy/storage/q5/g5/q5g55ekmscu10pzfw6q4syigt81g +0 -0
  100. data/test/dummy/storage/q5/kc/q5kcr9twyb9v4mh31pay0t7nkuwu +0 -0
  101. data/test/dummy/storage/qa/xd/qaxdngi74r52ahqg1pz8hjddeajc +0 -0
  102. data/test/dummy/storage/r7/5v/r75vadn34ak53vinylgnfdl1s8rt +0 -0
  103. data/test/dummy/storage/rj/rg/rjrghnyzyvxpkjw1a57mrloz72x1 +0 -0
  104. data/test/dummy/storage/se/h7/seh7eorfoanpp6de62pubv7kyu1a +0 -0
  105. data/test/dummy/storage/sj/i1/sji1oj12soz2fcjcoz0gejvzo8to +0 -0
  106. data/test/dummy/storage/sn/2r/sn2rku9thay4hbcbt926an69maku +0 -0
  107. data/test/dummy/storage/sw/jm/swjmbmxou3tnarcirxc6gdycxh91 +0 -0
  108. data/test/dummy/storage/sz/mq/szmqlydvpgqaw7p3v0wh444wtcif +0 -0
  109. data/test/dummy/storage/test.sqlite3 +0 -0
  110. data/test/dummy/storage/tg/by/tgbyrdvg94ivhhy2z59e8l9fod10 +0 -0
  111. data/test/dummy/storage/u5/vm/u5vmz08tuayqggd436et8fiaeml1 +0 -0
  112. data/test/dummy/storage/u6/pf/u6pf4yky0vbmvid3fa3lm4lre68g +0 -0
  113. data/test/dummy/storage/ub/ql/ubqlmlilt8ujgdpngcm1zae41kgy +0 -0
  114. data/test/dummy/storage/un/29/un29e9khqism72ag27ojccmn5sds +0 -0
  115. data/test/dummy/storage/ux/ns/uxnsvuk4rr1p67n1oq6tmraz0gaw +0 -0
  116. data/test/dummy/storage/v1/qo/v1qor0zxg3lctk9mbwyos3oag9gj +0 -0
  117. data/test/dummy/storage/v8/ok/v8okmd7374w1obna13a7anllx2vu +0 -0
  118. data/test/dummy/storage/vd/tf/vdtfmz2ctis3dr1r35do9bow2xj5 +0 -0
  119. data/test/dummy/storage/vo/dg/vodgq1inccnujjt3auber7tt8w8o +0 -0
  120. data/test/dummy/storage/vp/oe/vpoeiq00tf9pk0jcjlccomkju1zc +0 -0
  121. data/test/dummy/storage/vu/kg/vukgoj6qf96bhealui2yaeyn4n72 +0 -0
  122. data/test/dummy/storage/w7/2z/w72zoqu7v6v6jp0tpy671dcbvpow +0 -0
  123. data/test/dummy/storage/wa/3f/wa3fncsnozc6n4xfu32gw34geqcd +0 -0
  124. data/test/dummy/storage/wy/ix/wyixbqx3f6a4agb8bjhrtpblpaua +0 -0
  125. data/test/dummy/storage/xd/st/xdsttma3tqt7mex0vhp1vsm3dq16 +0 -0
  126. data/test/dummy/storage/xv/ej/xvejm2e064bnpunx3nmktaqs0x90 +0 -0
  127. data/test/dummy/storage/xx/py/xxpyyodssq2xmp57qrtvuw0wchwk +0 -0
  128. data/test/dummy/storage/xz/ik/xzikejc5sohi3zexa93s9xmg4jst +0 -0
  129. data/test/dummy/storage/y4/g8/y4g8teo86blcv0zysa2d2jawvk6i +0 -0
  130. data/test/dummy/storage/y9/58/y958xli6aoktx1ehuyjc1k8dcbzv +0 -0
  131. data/test/dummy/storage/yj/lw/yjlw8bf70iujb16deja8ae43rqbc +0 -0
  132. data/test/dummy/storage/z3/qy/z3qyb9avbucwhxa8909rpfued0y5 +0 -0
  133. data/test/dummy/storage/zr/wu/zrwudcg4kgo7r0jemszuzok8grqp +0 -0
  134. data/test/dummy/tmp/local_secret.txt +1 -0
  135. data/test/integration/encrypted_blob_proxy_controller_test.rb +253 -0
  136. data/test/integration/encrypted_blobs_controller_test.rb +0 -130
  137. data/test/lib/encrypted_disk_service_test.rb +5 -119
  138. data/test/lib/encrypted_mirror_service_test.rb +1 -1
  139. data/test/lib/encrypted_s3_service_test.rb +5 -2
  140. metadata +137 -4
  141. data/test/dummy/storage/x6/pl/x6plznfuhrsyjn9pox2a6xgmcs3x +0 -0
  142. 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: 1f97954b59908255d0d2e09943e111ca33af803ca47d3a9af06a44e23064f725
4
+ data.tar.gz: 700a8909d6492c8a8a8633c24a15418428c5e740d15adeef2cfcb15dcf872ac9
5
5
  SHA512:
6
- metadata.gz: 2d73fa7ea374c47f4fea531791db5962d469c8e1a95e9e7f9b0f64b9904e1b5948fd298b788b2a6c93a2e8beff0d58fde6ba24aa00c3c852b316232780a4ba4e
7
- data.tar.gz: 7a2427499a85bec52196dfbdf304193618febcf7c3ceb0eb0944a01452683dbbb2ff2304a716ae3694fb9f55f6683c337b4d19189f67ce034abe8a58e093a0f5
6
+ metadata.gz: 5eef06c92277075e3b5b9c5b4421f6b8352956b48f29b442d70b777ce737326f0470a728903ae1a42994792deb2d026b30b7497ea01c9ed723965e2e03c0d0d9
7
+ data.tar.gz: d94575af669034bccf27f41de435c44219f247d71c8d19e3c24d27e0af5301e55e53afccd141e1c5047921880836380aa414f628f4602cafbce82831ceb35d5a
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
 
@@ -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.1.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.1.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,
@@ -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.0"
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"