├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .ruby-version ├── .standard.yml ├── Appraisals ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── active_storage_encryption.gemspec ├── bin ├── rails └── rubocop ├── config ├── initializers │ └── active_storage_encryption.rb └── routes.rb ├── gemfiles ├── rails_7.gemfile ├── rails_7.gemfile.lock ├── rails_8.gemfile └── rails_8.gemfile.lock ├── lib ├── active_storage │ └── service │ │ ├── encrypted_disk_service.rb │ │ ├── encrypted_gcs_service.rb │ │ ├── encrypted_mirror_service.rb │ │ └── encrypted_s3_service.rb ├── active_storage_encryption.rb ├── active_storage_encryption │ ├── encrypted_blob_proxy_controller.rb │ ├── encrypted_blobs_controller.rb │ ├── encrypted_disk_service.rb │ ├── encrypted_disk_service │ │ ├── v1_scheme.rb │ │ └── v2_scheme.rb │ ├── encrypted_gcs_service.rb │ ├── encrypted_mirror_service.rb │ ├── encrypted_s3_service.rb │ ├── engine.rb │ ├── overrides.rb │ ├── private_url_policy.rb │ ├── resumable_gcs_upload.rb │ └── version.rb ├── generators │ ├── add_encryption_key_to_active_storage_blobs.rb.erb │ └── install_generator.rb └── tasks │ └── active_storage_encryption_tasks.rake └── test ├── active_storage_encryption_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ └── user.rb │ └── views │ │ ├── layouts │ │ └── application.html.erb │ │ └── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── credentials.yml.enc │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── content_security_policy.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ └── permissions_policy.rb │ ├── locales │ │ └── en.yml │ ├── master.key │ ├── puma.rb │ ├── routes.rb │ └── storage.yml ├── db │ ├── migrate │ │ ├── 20250304023851_create_active_storage_tables.active_storage.rb │ │ ├── 20250304023853_add_blob_encryption_key_column.rb │ │ └── 20250428093315_create_users.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep └── public │ ├── 404.html │ ├── 406-unsupported-browser.html │ ├── 422.html │ ├── 500.html │ ├── icon.png │ └── icon.svg ├── integration ├── .keep ├── encrypted_blob_proxy_controller_test.rb └── encrypted_blobs_controller_test.rb ├── lib ├── encrypted_disk_service_test.rb ├── encrypted_gcs_service_test.rb ├── encrypted_mirror_service_test.rb ├── encrypted_s3_service_test.rb └── overrides_test.rb └── test_helper.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 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 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 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 15 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.1 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "rails-7" do 2 | gem "rails", "< 8.0" 3 | gem "stringio" 4 | end 5 | 6 | appraise "rails-8" do 7 | gem "rails", ">= 8.0" 8 | gem "stringio" 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in active_storage_encryption.gemspec. 6 | gemspec 7 | 8 | # Start debugger with binding.b [https://github.com/ruby/debug] 9 | # gem "debug", ">= 1.0.0" 10 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright Cheddar Payments BV, Julik Tarkhanov, Sebastian van Hesteren 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "bundler/gem_tasks" 5 | 6 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 7 | load "rails/tasks/engine.rake" 8 | load "rails/tasks/statistics.rake" 9 | 10 | task :format do 11 | `bundle exec standardrb --fix` 12 | `bundle exec magic_frozen_string_literal .` 13 | end 14 | 15 | task default: ["app:test"] 16 | -------------------------------------------------------------------------------- /active_storage_encryption.gemspec: -------------------------------------------------------------------------------- 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 | spec.add_development_dependency "google-cloud-storage" 39 | 40 | # Code formatting, linting and testing 41 | spec.add_development_dependency "sqlite3" 42 | spec.add_development_dependency "standard", ">= 1.35.1" 43 | spec.add_development_dependency "appraisal" 44 | spec.add_development_dependency "magic_frozen_string_literal" 45 | spec.add_development_dependency "rake" 46 | spec.add_development_dependency "pry" 47 | end 48 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/active_storage_encryption/engine", __dir__) 7 | APP_PATH = File.expand_path("../test/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails" 14 | # Pick the frameworks you want: 15 | require "active_model/railtie" 16 | # require "active_job/railtie" 17 | require "active_record/railtie" 18 | # require "active_storage/engine" 19 | require "action_controller/railtie" 20 | # require "action_mailer/railtie" 21 | # require "action_mailbox/engine" 22 | # require "action_text/engine" 23 | require "action_view/railtie" 24 | # require "action_cable/engine" 25 | require "rails/test_unit/railtie" 26 | require "rails/engine/commands" 27 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /config/initializers/active_storage_encryption.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveSupport::Reloader.to_prepare do 4 | require "active_storage_encryption" 5 | ActiveStorage::Blob.send(:include, ActiveStorageEncryption::Overrides::EncryptedBlobClassMethods) 6 | ActiveStorage::Blob.send(:prepend, ActiveStorageEncryption::Overrides::EncryptedBlobInstanceMethods) 7 | ActiveStorage::Blob::Identifiable.send(:prepend, ActiveStorageEncryption::Overrides::BlobIdentifiableInstanceMethods) 8 | ActiveStorage::Downloader.send(:prepend, ActiveStorageEncryption::Overrides::DownloaderInstanceMethods) 9 | end 10 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveStorageEncryption::Engine.routes.draw do 4 | put "/blob/:token", to: "encrypted_blobs#update", as: "encrypted_blob_put" 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 | end 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "< 8.0" 6 | gem "stringio" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_storage_encryption (0.3.0) 5 | activestorage 6 | block_cipher_kit (>= 0.0.4) 7 | rails (>= 7.2.2.1) 8 | serve_byte_range (~> 1.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (7.2.2.1) 14 | actionpack (= 7.2.2.1) 15 | activesupport (= 7.2.2.1) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | zeitwerk (~> 2.6) 19 | actionmailbox (7.2.2.1) 20 | actionpack (= 7.2.2.1) 21 | activejob (= 7.2.2.1) 22 | activerecord (= 7.2.2.1) 23 | activestorage (= 7.2.2.1) 24 | activesupport (= 7.2.2.1) 25 | mail (>= 2.8.0) 26 | actionmailer (7.2.2.1) 27 | actionpack (= 7.2.2.1) 28 | actionview (= 7.2.2.1) 29 | activejob (= 7.2.2.1) 30 | activesupport (= 7.2.2.1) 31 | mail (>= 2.8.0) 32 | rails-dom-testing (~> 2.2) 33 | actionpack (7.2.2.1) 34 | actionview (= 7.2.2.1) 35 | activesupport (= 7.2.2.1) 36 | nokogiri (>= 1.8.5) 37 | racc 38 | rack (>= 2.2.4, < 3.2) 39 | rack-session (>= 1.0.1) 40 | rack-test (>= 0.6.3) 41 | rails-dom-testing (~> 2.2) 42 | rails-html-sanitizer (~> 1.6) 43 | useragent (~> 0.16) 44 | actiontext (7.2.2.1) 45 | actionpack (= 7.2.2.1) 46 | activerecord (= 7.2.2.1) 47 | activestorage (= 7.2.2.1) 48 | activesupport (= 7.2.2.1) 49 | globalid (>= 0.6.0) 50 | nokogiri (>= 1.8.5) 51 | actionview (7.2.2.1) 52 | activesupport (= 7.2.2.1) 53 | builder (~> 3.1) 54 | erubi (~> 1.11) 55 | rails-dom-testing (~> 2.2) 56 | rails-html-sanitizer (~> 1.6) 57 | activejob (7.2.2.1) 58 | activesupport (= 7.2.2.1) 59 | globalid (>= 0.3.6) 60 | activemodel (7.2.2.1) 61 | activesupport (= 7.2.2.1) 62 | activerecord (7.2.2.1) 63 | activemodel (= 7.2.2.1) 64 | activesupport (= 7.2.2.1) 65 | timeout (>= 0.4.0) 66 | activestorage (7.2.2.1) 67 | actionpack (= 7.2.2.1) 68 | activejob (= 7.2.2.1) 69 | activerecord (= 7.2.2.1) 70 | activesupport (= 7.2.2.1) 71 | marcel (~> 1.0) 72 | activesupport (7.2.2.1) 73 | base64 74 | benchmark (>= 0.3) 75 | bigdecimal 76 | concurrent-ruby (~> 1.0, >= 1.3.1) 77 | connection_pool (>= 2.2.5) 78 | drb 79 | i18n (>= 1.6, < 2) 80 | logger (>= 1.4.2) 81 | minitest (>= 5.1) 82 | securerandom (>= 0.3) 83 | tzinfo (~> 2.0, >= 2.0.5) 84 | addressable (2.8.7) 85 | public_suffix (>= 2.0.2, < 7.0) 86 | appraisal (2.5.0) 87 | bundler 88 | rake 89 | thor (>= 0.14.0) 90 | ast (2.4.2) 91 | aws-eventstream (1.3.1) 92 | aws-partitions (1.1060.0) 93 | aws-sdk-core (3.220.0) 94 | aws-eventstream (~> 1, >= 1.3.0) 95 | aws-partitions (~> 1, >= 1.992.0) 96 | aws-sigv4 (~> 1.9) 97 | base64 98 | jmespath (~> 1, >= 1.6.1) 99 | aws-sdk-kms (1.99.0) 100 | aws-sdk-core (~> 3, >= 3.216.0) 101 | aws-sigv4 (~> 1.5) 102 | aws-sdk-s3 (1.182.0) 103 | aws-sdk-core (~> 3, >= 3.216.0) 104 | aws-sdk-kms (~> 1) 105 | aws-sigv4 (~> 1.5) 106 | aws-sigv4 (1.11.0) 107 | aws-eventstream (~> 1, >= 1.0.2) 108 | base64 (0.2.0) 109 | benchmark (0.4.0) 110 | bigdecimal (3.1.9) 111 | block_cipher_kit (0.0.4) 112 | builder (3.3.0) 113 | coderay (1.1.3) 114 | concurrent-ruby (1.3.5) 115 | connection_pool (2.5.0) 116 | crass (1.0.6) 117 | date (3.4.1) 118 | declarative (0.0.20) 119 | digest-crc (0.7.0) 120 | rake (>= 12.0.0, < 14.0.0) 121 | drb (2.2.1) 122 | erubi (1.13.1) 123 | faraday (2.13.0) 124 | faraday-net_http (>= 2.0, < 3.5) 125 | json 126 | logger 127 | faraday-net_http (3.4.0) 128 | net-http (>= 0.5.0) 129 | globalid (1.2.1) 130 | activesupport (>= 6.1) 131 | google-apis-core (0.16.0) 132 | addressable (~> 2.5, >= 2.5.1) 133 | googleauth (~> 1.9) 134 | httpclient (>= 2.8.3, < 3.a) 135 | mini_mime (~> 1.0) 136 | mutex_m 137 | representable (~> 3.0) 138 | retriable (>= 2.0, < 4.a) 139 | google-apis-iamcredentials_v1 (0.22.0) 140 | google-apis-core (>= 0.15.0, < 2.a) 141 | google-apis-storage_v1 (0.50.0) 142 | google-apis-core (>= 0.15.0, < 2.a) 143 | google-cloud-core (1.8.0) 144 | google-cloud-env (>= 1.0, < 3.a) 145 | google-cloud-errors (~> 1.0) 146 | google-cloud-env (2.2.2) 147 | base64 (~> 0.2) 148 | faraday (>= 1.0, < 3.a) 149 | google-cloud-errors (1.5.0) 150 | google-cloud-storage (1.56.0) 151 | addressable (~> 2.8) 152 | digest-crc (~> 0.4) 153 | google-apis-core (~> 0.13) 154 | google-apis-iamcredentials_v1 (~> 0.18) 155 | google-apis-storage_v1 (>= 0.42) 156 | google-cloud-core (~> 1.6) 157 | googleauth (~> 1.9) 158 | mini_mime (~> 1.0) 159 | google-logging-utils (0.1.0) 160 | googleauth (1.14.0) 161 | faraday (>= 1.0, < 3.a) 162 | google-cloud-env (~> 2.2) 163 | google-logging-utils (~> 0.1) 164 | jwt (>= 1.4, < 3.0) 165 | multi_json (~> 1.11) 166 | os (>= 0.9, < 2.0) 167 | signet (>= 0.16, < 2.a) 168 | httpclient (2.9.0) 169 | mutex_m 170 | i18n (1.14.7) 171 | concurrent-ruby (~> 1.0) 172 | io-console (0.8.0) 173 | irb (1.15.1) 174 | pp (>= 0.6.0) 175 | rdoc (>= 4.0.0) 176 | reline (>= 0.4.2) 177 | jmespath (1.6.2) 178 | json (2.10.1) 179 | jwt (2.10.1) 180 | base64 181 | language_server-protocol (3.17.0.4) 182 | lint_roller (1.1.0) 183 | logger (1.6.6) 184 | loofah (2.24.0) 185 | crass (~> 1.0.2) 186 | nokogiri (>= 1.12.0) 187 | magic_frozen_string_literal (1.2.0) 188 | mail (2.8.1) 189 | mini_mime (>= 0.1.1) 190 | net-imap 191 | net-pop 192 | net-smtp 193 | marcel (1.0.4) 194 | method_source (1.1.0) 195 | mini_mime (1.1.5) 196 | minitest (5.25.4) 197 | multi_json (1.15.0) 198 | mutex_m (0.3.0) 199 | net-http (0.6.0) 200 | uri 201 | net-imap (0.5.6) 202 | date 203 | net-protocol 204 | net-pop (0.1.2) 205 | net-protocol 206 | net-protocol (0.2.2) 207 | timeout 208 | net-smtp (0.5.1) 209 | net-protocol 210 | nio4r (2.7.4) 211 | nokogiri (1.18.3-arm64-darwin) 212 | racc (~> 1.4) 213 | nokogiri (1.18.3-x86_64-darwin) 214 | racc (~> 1.4) 215 | nokogiri (1.18.3-x86_64-linux-gnu) 216 | racc (~> 1.4) 217 | os (1.1.4) 218 | parallel (1.26.3) 219 | parser (3.3.7.1) 220 | ast (~> 2.4.1) 221 | racc 222 | pp (0.6.2) 223 | prettyprint 224 | prettyprint (0.2.0) 225 | pry (0.15.2) 226 | coderay (~> 1.1) 227 | method_source (~> 1.0) 228 | psych (5.2.3) 229 | date 230 | stringio 231 | public_suffix (6.0.1) 232 | racc (1.8.1) 233 | rack (3.1.11) 234 | rack-session (2.1.0) 235 | base64 (>= 0.1.0) 236 | rack (>= 3.0.0) 237 | rack-test (2.2.0) 238 | rack (>= 1.3) 239 | rackup (2.2.1) 240 | rack (>= 3) 241 | rails (7.2.2.1) 242 | actioncable (= 7.2.2.1) 243 | actionmailbox (= 7.2.2.1) 244 | actionmailer (= 7.2.2.1) 245 | actionpack (= 7.2.2.1) 246 | actiontext (= 7.2.2.1) 247 | actionview (= 7.2.2.1) 248 | activejob (= 7.2.2.1) 249 | activemodel (= 7.2.2.1) 250 | activerecord (= 7.2.2.1) 251 | activestorage (= 7.2.2.1) 252 | activesupport (= 7.2.2.1) 253 | bundler (>= 1.15.0) 254 | railties (= 7.2.2.1) 255 | rails-dom-testing (2.2.0) 256 | activesupport (>= 5.0.0) 257 | minitest 258 | nokogiri (>= 1.6) 259 | rails-html-sanitizer (1.6.2) 260 | loofah (~> 2.21) 261 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 262 | railties (7.2.2.1) 263 | actionpack (= 7.2.2.1) 264 | activesupport (= 7.2.2.1) 265 | irb (~> 1.13) 266 | rackup (>= 1.0.0) 267 | rake (>= 12.2) 268 | thor (~> 1.0, >= 1.2.2) 269 | zeitwerk (~> 2.6) 270 | rainbow (3.1.1) 271 | rake (13.2.1) 272 | rdoc (6.12.0) 273 | psych (>= 4.0.0) 274 | regexp_parser (2.10.0) 275 | reline (0.6.0) 276 | io-console (~> 0.5) 277 | representable (3.2.0) 278 | declarative (< 0.1.0) 279 | trailblazer-option (>= 0.1.1, < 0.2.0) 280 | uber (< 0.2.0) 281 | retriable (3.1.2) 282 | rubocop (1.71.2) 283 | json (~> 2.3) 284 | language_server-protocol (>= 3.17.0) 285 | parallel (~> 1.10) 286 | parser (>= 3.3.0.2) 287 | rainbow (>= 2.2.2, < 4.0) 288 | regexp_parser (>= 2.9.3, < 3.0) 289 | rubocop-ast (>= 1.38.0, < 2.0) 290 | ruby-progressbar (~> 1.7) 291 | unicode-display_width (>= 2.4.0, < 4.0) 292 | rubocop-ast (1.38.1) 293 | parser (>= 3.3.1.0) 294 | rubocop-performance (1.23.1) 295 | rubocop (>= 1.48.1, < 2.0) 296 | rubocop-ast (>= 1.31.1, < 2.0) 297 | ruby-progressbar (1.13.0) 298 | securerandom (0.4.1) 299 | serve_byte_range (1.0.0) 300 | rack (>= 1.0) 301 | signet (0.19.0) 302 | addressable (~> 2.8) 303 | faraday (>= 0.17.5, < 3.a) 304 | jwt (>= 1.5, < 3.0) 305 | multi_json (~> 1.10) 306 | sqlite3 (2.6.0-arm64-darwin) 307 | sqlite3 (2.6.0-x86_64-darwin) 308 | sqlite3 (2.6.0-x86_64-linux-gnu) 309 | standard (1.45.0) 310 | language_server-protocol (~> 3.17.0.2) 311 | lint_roller (~> 1.0) 312 | rubocop (~> 1.71.0) 313 | standard-custom (~> 1.0.0) 314 | standard-performance (~> 1.6) 315 | standard-custom (1.0.2) 316 | lint_roller (~> 1.0) 317 | rubocop (~> 1.50) 318 | standard-performance (1.6.0) 319 | lint_roller (~> 1.1) 320 | rubocop-performance (~> 1.23.0) 321 | stringio (3.1.5) 322 | thor (1.3.2) 323 | timeout (0.4.3) 324 | trailblazer-option (0.1.2) 325 | tzinfo (2.0.6) 326 | concurrent-ruby (~> 1.0) 327 | uber (0.1.0) 328 | unicode-display_width (3.1.4) 329 | unicode-emoji (~> 4.0, >= 4.0.4) 330 | unicode-emoji (4.0.4) 331 | uri (1.0.3) 332 | useragent (0.16.11) 333 | websocket-driver (0.7.7) 334 | base64 335 | websocket-extensions (>= 0.1.0) 336 | websocket-extensions (0.1.5) 337 | zeitwerk (2.7.2) 338 | 339 | PLATFORMS 340 | arm64-darwin-21 341 | arm64-darwin-23 342 | arm64-darwin-24 343 | x86_64-darwin 344 | x86_64-linux 345 | 346 | DEPENDENCIES 347 | active_storage_encryption! 348 | appraisal 349 | aws-sdk-s3 350 | google-cloud-storage 351 | magic_frozen_string_literal 352 | net-http 353 | pry 354 | rails (< 8.0) 355 | rake 356 | sqlite3 357 | standard (>= 1.35.1) 358 | stringio 359 | 360 | BUNDLED WITH 361 | 2.5.11 362 | -------------------------------------------------------------------------------- /gemfiles/rails_8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", ">= 8.0" 6 | gem "stringio" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/rails_8.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | active_storage_encryption (0.3.0) 5 | activestorage 6 | block_cipher_kit (>= 0.0.4) 7 | rails (>= 7.2.2.1) 8 | serve_byte_range (~> 1.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | actioncable (8.0.1) 14 | actionpack (= 8.0.1) 15 | activesupport (= 8.0.1) 16 | nio4r (~> 2.0) 17 | websocket-driver (>= 0.6.1) 18 | zeitwerk (~> 2.6) 19 | actionmailbox (8.0.1) 20 | actionpack (= 8.0.1) 21 | activejob (= 8.0.1) 22 | activerecord (= 8.0.1) 23 | activestorage (= 8.0.1) 24 | activesupport (= 8.0.1) 25 | mail (>= 2.8.0) 26 | actionmailer (8.0.1) 27 | actionpack (= 8.0.1) 28 | actionview (= 8.0.1) 29 | activejob (= 8.0.1) 30 | activesupport (= 8.0.1) 31 | mail (>= 2.8.0) 32 | rails-dom-testing (~> 2.2) 33 | actionpack (8.0.1) 34 | actionview (= 8.0.1) 35 | activesupport (= 8.0.1) 36 | nokogiri (>= 1.8.5) 37 | rack (>= 2.2.4) 38 | rack-session (>= 1.0.1) 39 | rack-test (>= 0.6.3) 40 | rails-dom-testing (~> 2.2) 41 | rails-html-sanitizer (~> 1.6) 42 | useragent (~> 0.16) 43 | actiontext (8.0.1) 44 | actionpack (= 8.0.1) 45 | activerecord (= 8.0.1) 46 | activestorage (= 8.0.1) 47 | activesupport (= 8.0.1) 48 | globalid (>= 0.6.0) 49 | nokogiri (>= 1.8.5) 50 | actionview (8.0.1) 51 | activesupport (= 8.0.1) 52 | builder (~> 3.1) 53 | erubi (~> 1.11) 54 | rails-dom-testing (~> 2.2) 55 | rails-html-sanitizer (~> 1.6) 56 | activejob (8.0.1) 57 | activesupport (= 8.0.1) 58 | globalid (>= 0.3.6) 59 | activemodel (8.0.1) 60 | activesupport (= 8.0.1) 61 | activerecord (8.0.1) 62 | activemodel (= 8.0.1) 63 | activesupport (= 8.0.1) 64 | timeout (>= 0.4.0) 65 | activestorage (8.0.1) 66 | actionpack (= 8.0.1) 67 | activejob (= 8.0.1) 68 | activerecord (= 8.0.1) 69 | activesupport (= 8.0.1) 70 | marcel (~> 1.0) 71 | activesupport (8.0.1) 72 | base64 73 | benchmark (>= 0.3) 74 | bigdecimal 75 | concurrent-ruby (~> 1.0, >= 1.3.1) 76 | connection_pool (>= 2.2.5) 77 | drb 78 | i18n (>= 1.6, < 2) 79 | logger (>= 1.4.2) 80 | minitest (>= 5.1) 81 | securerandom (>= 0.3) 82 | tzinfo (~> 2.0, >= 2.0.5) 83 | uri (>= 0.13.1) 84 | addressable (2.8.7) 85 | public_suffix (>= 2.0.2, < 7.0) 86 | appraisal (2.5.0) 87 | bundler 88 | rake 89 | thor (>= 0.14.0) 90 | ast (2.4.2) 91 | aws-eventstream (1.3.1) 92 | aws-partitions (1.1060.0) 93 | aws-sdk-core (3.220.0) 94 | aws-eventstream (~> 1, >= 1.3.0) 95 | aws-partitions (~> 1, >= 1.992.0) 96 | aws-sigv4 (~> 1.9) 97 | base64 98 | jmespath (~> 1, >= 1.6.1) 99 | aws-sdk-kms (1.99.0) 100 | aws-sdk-core (~> 3, >= 3.216.0) 101 | aws-sigv4 (~> 1.5) 102 | aws-sdk-s3 (1.182.0) 103 | aws-sdk-core (~> 3, >= 3.216.0) 104 | aws-sdk-kms (~> 1) 105 | aws-sigv4 (~> 1.5) 106 | aws-sigv4 (1.11.0) 107 | aws-eventstream (~> 1, >= 1.0.2) 108 | base64 (0.2.0) 109 | benchmark (0.4.0) 110 | bigdecimal (3.1.9) 111 | block_cipher_kit (0.0.4) 112 | builder (3.3.0) 113 | coderay (1.1.3) 114 | concurrent-ruby (1.3.5) 115 | connection_pool (2.5.0) 116 | crass (1.0.6) 117 | date (3.4.1) 118 | declarative (0.0.20) 119 | digest-crc (0.7.0) 120 | rake (>= 12.0.0, < 14.0.0) 121 | drb (2.2.1) 122 | erubi (1.13.1) 123 | faraday (2.13.0) 124 | faraday-net_http (>= 2.0, < 3.5) 125 | json 126 | logger 127 | faraday-net_http (3.4.0) 128 | net-http (>= 0.5.0) 129 | globalid (1.2.1) 130 | activesupport (>= 6.1) 131 | google-apis-core (0.16.0) 132 | addressable (~> 2.5, >= 2.5.1) 133 | googleauth (~> 1.9) 134 | httpclient (>= 2.8.3, < 3.a) 135 | mini_mime (~> 1.0) 136 | mutex_m 137 | representable (~> 3.0) 138 | retriable (>= 2.0, < 4.a) 139 | google-apis-iamcredentials_v1 (0.22.0) 140 | google-apis-core (>= 0.15.0, < 2.a) 141 | google-apis-storage_v1 (0.50.0) 142 | google-apis-core (>= 0.15.0, < 2.a) 143 | google-cloud-core (1.8.0) 144 | google-cloud-env (>= 1.0, < 3.a) 145 | google-cloud-errors (~> 1.0) 146 | google-cloud-env (2.2.2) 147 | base64 (~> 0.2) 148 | faraday (>= 1.0, < 3.a) 149 | google-cloud-errors (1.5.0) 150 | google-cloud-storage (1.56.0) 151 | addressable (~> 2.8) 152 | digest-crc (~> 0.4) 153 | google-apis-core (~> 0.13) 154 | google-apis-iamcredentials_v1 (~> 0.18) 155 | google-apis-storage_v1 (>= 0.42) 156 | google-cloud-core (~> 1.6) 157 | googleauth (~> 1.9) 158 | mini_mime (~> 1.0) 159 | google-logging-utils (0.1.0) 160 | googleauth (1.14.0) 161 | faraday (>= 1.0, < 3.a) 162 | google-cloud-env (~> 2.2) 163 | google-logging-utils (~> 0.1) 164 | jwt (>= 1.4, < 3.0) 165 | multi_json (~> 1.11) 166 | os (>= 0.9, < 2.0) 167 | signet (>= 0.16, < 2.a) 168 | httpclient (2.9.0) 169 | mutex_m 170 | i18n (1.14.7) 171 | concurrent-ruby (~> 1.0) 172 | io-console (0.8.0) 173 | irb (1.15.1) 174 | pp (>= 0.6.0) 175 | rdoc (>= 4.0.0) 176 | reline (>= 0.4.2) 177 | jmespath (1.6.2) 178 | json (2.10.1) 179 | jwt (2.10.1) 180 | base64 181 | language_server-protocol (3.17.0.4) 182 | lint_roller (1.1.0) 183 | logger (1.6.6) 184 | loofah (2.24.0) 185 | crass (~> 1.0.2) 186 | nokogiri (>= 1.12.0) 187 | magic_frozen_string_literal (1.2.0) 188 | mail (2.8.1) 189 | mini_mime (>= 0.1.1) 190 | net-imap 191 | net-pop 192 | net-smtp 193 | marcel (1.0.4) 194 | method_source (1.1.0) 195 | mini_mime (1.1.5) 196 | minitest (5.25.4) 197 | multi_json (1.15.0) 198 | mutex_m (0.3.0) 199 | net-http (0.6.0) 200 | uri 201 | net-imap (0.5.6) 202 | date 203 | net-protocol 204 | net-pop (0.1.2) 205 | net-protocol 206 | net-protocol (0.2.2) 207 | timeout 208 | net-smtp (0.5.1) 209 | net-protocol 210 | nio4r (2.7.4) 211 | nokogiri (1.18.3-arm64-darwin) 212 | racc (~> 1.4) 213 | nokogiri (1.18.3-x86_64-darwin) 214 | racc (~> 1.4) 215 | nokogiri (1.18.3-x86_64-linux-gnu) 216 | racc (~> 1.4) 217 | os (1.1.4) 218 | parallel (1.26.3) 219 | parser (3.3.7.1) 220 | ast (~> 2.4.1) 221 | racc 222 | pp (0.6.2) 223 | prettyprint 224 | prettyprint (0.2.0) 225 | pry (0.15.2) 226 | coderay (~> 1.1) 227 | method_source (~> 1.0) 228 | psych (5.2.3) 229 | date 230 | stringio 231 | public_suffix (6.0.1) 232 | racc (1.8.1) 233 | rack (3.1.11) 234 | rack-session (2.1.0) 235 | base64 (>= 0.1.0) 236 | rack (>= 3.0.0) 237 | rack-test (2.2.0) 238 | rack (>= 1.3) 239 | rackup (2.2.1) 240 | rack (>= 3) 241 | rails (8.0.1) 242 | actioncable (= 8.0.1) 243 | actionmailbox (= 8.0.1) 244 | actionmailer (= 8.0.1) 245 | actionpack (= 8.0.1) 246 | actiontext (= 8.0.1) 247 | actionview (= 8.0.1) 248 | activejob (= 8.0.1) 249 | activemodel (= 8.0.1) 250 | activerecord (= 8.0.1) 251 | activestorage (= 8.0.1) 252 | activesupport (= 8.0.1) 253 | bundler (>= 1.15.0) 254 | railties (= 8.0.1) 255 | rails-dom-testing (2.2.0) 256 | activesupport (>= 5.0.0) 257 | minitest 258 | nokogiri (>= 1.6) 259 | rails-html-sanitizer (1.6.2) 260 | loofah (~> 2.21) 261 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 262 | railties (8.0.1) 263 | actionpack (= 8.0.1) 264 | activesupport (= 8.0.1) 265 | irb (~> 1.13) 266 | rackup (>= 1.0.0) 267 | rake (>= 12.2) 268 | thor (~> 1.0, >= 1.2.2) 269 | zeitwerk (~> 2.6) 270 | rainbow (3.1.1) 271 | rake (13.2.1) 272 | rdoc (6.12.0) 273 | psych (>= 4.0.0) 274 | regexp_parser (2.10.0) 275 | reline (0.6.0) 276 | io-console (~> 0.5) 277 | representable (3.2.0) 278 | declarative (< 0.1.0) 279 | trailblazer-option (>= 0.1.1, < 0.2.0) 280 | uber (< 0.2.0) 281 | retriable (3.1.2) 282 | rubocop (1.71.2) 283 | json (~> 2.3) 284 | language_server-protocol (>= 3.17.0) 285 | parallel (~> 1.10) 286 | parser (>= 3.3.0.2) 287 | rainbow (>= 2.2.2, < 4.0) 288 | regexp_parser (>= 2.9.3, < 3.0) 289 | rubocop-ast (>= 1.38.0, < 2.0) 290 | ruby-progressbar (~> 1.7) 291 | unicode-display_width (>= 2.4.0, < 4.0) 292 | rubocop-ast (1.38.1) 293 | parser (>= 3.3.1.0) 294 | rubocop-performance (1.23.1) 295 | rubocop (>= 1.48.1, < 2.0) 296 | rubocop-ast (>= 1.31.1, < 2.0) 297 | ruby-progressbar (1.13.0) 298 | securerandom (0.4.1) 299 | serve_byte_range (1.0.0) 300 | rack (>= 1.0) 301 | signet (0.19.0) 302 | addressable (~> 2.8) 303 | faraday (>= 0.17.5, < 3.a) 304 | jwt (>= 1.5, < 3.0) 305 | multi_json (~> 1.10) 306 | sqlite3 (2.6.0-arm64-darwin) 307 | sqlite3 (2.6.0-x86_64-darwin) 308 | sqlite3 (2.6.0-x86_64-linux-gnu) 309 | standard (1.45.0) 310 | language_server-protocol (~> 3.17.0.2) 311 | lint_roller (~> 1.0) 312 | rubocop (~> 1.71.0) 313 | standard-custom (~> 1.0.0) 314 | standard-performance (~> 1.6) 315 | standard-custom (1.0.2) 316 | lint_roller (~> 1.0) 317 | rubocop (~> 1.50) 318 | standard-performance (1.6.0) 319 | lint_roller (~> 1.1) 320 | rubocop-performance (~> 1.23.0) 321 | stringio (3.1.5) 322 | thor (1.3.2) 323 | timeout (0.4.3) 324 | trailblazer-option (0.1.2) 325 | tzinfo (2.0.6) 326 | concurrent-ruby (~> 1.0) 327 | uber (0.1.0) 328 | unicode-display_width (3.1.4) 329 | unicode-emoji (~> 4.0, >= 4.0.4) 330 | unicode-emoji (4.0.4) 331 | uri (1.0.3) 332 | useragent (0.16.11) 333 | websocket-driver (0.7.7) 334 | base64 335 | websocket-extensions (>= 0.1.0) 336 | websocket-extensions (0.1.5) 337 | zeitwerk (2.7.2) 338 | 339 | PLATFORMS 340 | arm64-darwin-21 341 | arm64-darwin-23 342 | arm64-darwin-24 343 | x86_64-darwin 344 | x86_64-linux 345 | 346 | DEPENDENCIES 347 | active_storage_encryption! 348 | appraisal 349 | aws-sdk-s3 350 | google-cloud-storage 351 | magic_frozen_string_literal 352 | net-http 353 | pry 354 | rails (>= 8.0) 355 | rake 356 | sqlite3 357 | standard (>= 1.35.1) 358 | stringio 359 | 360 | BUNDLED WITH 361 | 2.5.11 362 | -------------------------------------------------------------------------------- /lib/active_storage/service/encrypted_disk_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Needed so that Rails can find our service definition. It will perform the following 4 | # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will: 5 | # 6 | # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH 7 | # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService" 8 | require_relative "../../active_storage_encryption" 9 | class ActiveStorage::Service::EncryptedDiskService < ActiveStorageEncryption::EncryptedDiskService 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_storage/service/encrypted_gcs_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Needed so that Rails can find our service definition. It will perform the following 4 | # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will: 5 | # 6 | # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH 7 | # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService" 8 | require_relative "../../active_storage_encryption" 9 | class ActiveStorage::Service::EncryptedGCSService < ActiveStorageEncryption::EncryptedGCSService 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_storage/service/encrypted_mirror_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Needed so that Rails can find our service definition. It will perform the following 4 | # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will: 5 | # 6 | # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH 7 | # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService" 8 | require_relative "../../active_storage_encryption/active_storage_encryption" 9 | class ActiveStorage::Service::EncryptedMirrorService < ActiveStorageEncryption::EncryptedMirrorService 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_storage/service/encrypted_s3_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Needed so that Rails can find our service definition. It will perform the following 4 | # steps. Given an "EncryptedDisk" value of the `service:` key in the YAML, it will: 5 | # 6 | # * Force-require a file at "active_storage/service/encrypted_disk", from any path on the $LOAD_PATH 7 | # * Instantiate a class called "ActiveStorage::Service::EncryptedDiskService" 8 | require_relative "../../active_storage_encryption" 9 | class ActiveStorage::Service::EncryptedS3Service < ActiveStorageEncryption::EncryptedS3Service 10 | end 11 | -------------------------------------------------------------------------------- /lib/active_storage_encryption.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage_encryption/version" 4 | require "active_storage_encryption/engine" 5 | 6 | module ActiveStorageEncryption 7 | autoload :PrivateUrlPolicy, __dir__ + "/active_storage_encryption/private_url_policy.rb" 8 | autoload :EncryptedBlobsController, __dir__ + "/active_storage_encryption/encrypted_blobs_controller.rb" 9 | autoload :EncryptedBlobProxyController, __dir__ + "/active_storage_encryption/encrypted_blob_proxy_controller.rb" 10 | autoload :EncryptedDiskService, __dir__ + "/active_storage_encryption/encrypted_disk_service.rb" 11 | autoload :EncryptedMirrorService, __dir__ + "/active_storage_encryption/encrypted_mirror_service.rb" 12 | autoload :EncryptedS3Service, __dir__ + "/active_storage_encryption/encrypted_s3_service.rb" 13 | autoload :EncryptedGCSService, __dir__ + "/active_storage_encryption/encrypted_gcs_service.rb" 14 | autoload :Overrides, __dir__ + "/active_storage_encryption/overrides.rb" 15 | 16 | class IncorrectEncryptionKey < ArgumentError 17 | end 18 | 19 | class StreamingDisabled < ArgumentError 20 | end 21 | 22 | class StreamingTokenInvalidOrExpired < ActiveSupport::MessageEncryptor::InvalidMessage 23 | end 24 | 25 | # Unlike MessageVerifier#verify, MessageEncryptor#decrypt_and_verify does not raise an exception if 26 | # the message decrypts, but has expired or was signed for a different purpose. We want an exception 27 | # to remove the annoying nil checks. 28 | class TokenEncryptor < ActiveSupport::MessageEncryptor 29 | def decrypt_and_verify(value, **options) 30 | super.tap do |message_or_nil| 31 | raise StreamingTokenInvalidOrExpired if message_or_nil.nil? 32 | end 33 | end 34 | end 35 | 36 | # Returns the ActiveSupport::MessageEncryptor which is used for encrypting the 37 | # streaming download URLs. These URLs need to contain the encryption key which 38 | # we do not want to reveal to the consumer. Note that this encryptor _is not_ 39 | # used to encrypt the file data itself - ActiveSupport::MessageEncryptor is not 40 | # fit for streaming and not designed for file encryption use cases. We just use 41 | # this encryptor to encrypt the tokens in URLs (which is something the MessageEncryptor) 42 | # is actually good at. 43 | # 44 | # The encryptor gets configured using a key derived from the Rails secrets, in a similar 45 | # manner to the MessageVerifier provided for your Rails app by the Rails bootstrapping code. 46 | # 47 | # @return [ActiveSupport::MessageEncryptor] the configured encryptor. 48 | def self.token_encryptor 49 | # Rails has a per-app message verifier, which is used for different purposes: 50 | # 51 | # Rails.application.message_verifier('sensitive_data') 52 | # 53 | # The ActiveStorage verifier (`ActiveStorage.verifier`) is actually just: 54 | # 55 | # Rails.application.message_verifier('ActiveStorage') 56 | # 57 | # Sadly, unlike the verifier, a Rails app does not have a similar centrally 58 | # set-up `message_encryptor`, specifying a sane configuration (secret, encryption 59 | # scheme et cetera). 60 | # 61 | # The initialization code for the Rails-wide verifiers (it is plural since Rails initializes 62 | # verifiers according to the argument you pass to `message_verifier(purpose_or_name_of_using_module)`: 63 | # ActiveSupport::MessageVerifiers.new do |salt, secret_key_base: self.secret_key_base| 64 | # key_generator(secret_key_base).generate_key(salt) 65 | # end.rotate_defaults 66 | # 67 | # The same API is actually supported by ActiveSupport::MessageEncryptors, see 68 | # https://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptors.html 69 | # but we do not need multiple encryptors - one will do :-) 70 | secret_key_base = Rails.application.secret_key_base 71 | raise ArgumentError, "secret_key_base must be present on Rails.application" unless secret_key_base 72 | 73 | len = TokenEncryptor.key_len 74 | salt = Digest::SHA2.digest("ActiveStorageEncryption") 75 | raise "Salt must be the same length as the key" unless salt.bytesize == len 76 | key = ActiveSupport::KeyGenerator.new(secret_key_base).generate_key(salt, len) 77 | 78 | # We need an URL-safe serializer, since the tokens are used in a path in URLs 79 | TokenEncryptor.new(key, url_safe: true) 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_blob_proxy_controller.rb: -------------------------------------------------------------------------------- 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, 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 117 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_blobs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveStorageEncryption::EncryptedBlobsController < ActionController::Base 4 | include ActiveStorage::SetCurrent 5 | 6 | class InvalidParams < StandardError 7 | end 8 | 9 | DEFAULT_BLOB_STREAMING_DISPOSITION = "inline" 10 | 11 | self.etag_with_template_digest = false 12 | skip_forgery_protection 13 | 14 | # Accepts PUT requests for direct uploads to the EncryptedDiskService. It can actually accept 15 | # uploads to any encrypted service, but for S3 and GCP the upload can be done to the cloud storage 16 | # bucket directly. 17 | def update 18 | params = read_params_from_token_and_headers_for_put 19 | service = lookup_service(params[:service_name]) 20 | key = params[:key] 21 | 22 | service.upload(key, request.body, 23 | content_type: params[:content_type], 24 | content_length: params[:content_length], 25 | checksum: params[:checksum], 26 | encryption_key: params[:encryption_key]) 27 | rescue InvalidParams, ActiveStorageEncryption::IncorrectEncryptionKey, ActiveSupport::MessageVerifier::InvalidSignature, ActiveStorage::IntegrityError 28 | head :unprocessable_entity 29 | end 30 | 31 | # Creates a Blob record with a random encryption key and returns the details for PUTing it 32 | # This is only necessary because in Rails there is some disagreement regarding the service_name parameter. 33 | # See https://github.com/rails/rails/issues/38940 34 | # It does not require the service to support encryption. However, we mandate that the MD5 be provided upfront, 35 | # so that it gets included into the signature 36 | def create_direct_upload 37 | blob_params = params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}) 38 | unless blob_params[:checksum] 39 | render(plain: "The `checksum' is required", status: :unprocessable_entity) and return 40 | end 41 | 42 | service = lookup_service(params.require(:service_name)) 43 | blob = ActiveStorage::Blob.create_before_direct_upload!( 44 | **blob_params.to_h.symbolize_keys, 45 | service_name: service.name 46 | ) 47 | render json: direct_upload_json(blob) 48 | end 49 | 50 | private 51 | 52 | def read_params_from_token_and_headers_for_put 53 | token_str = params.require(:token) 54 | 55 | # The token params for PUT / direct upload are signed but not encrypted - the encryption key 56 | # is transmitted inside headers 57 | token_params = ActiveStorage.verifier.verify(token_str, purpose: :encrypted_put).symbolize_keys 58 | 59 | # Ensure we are getting sent exactly as many bytes as stated in the token 60 | raise InvalidParams, "Request must specify body content-length" if request.headers["content-length"].blank? 61 | 62 | actual_content_length = request.headers["content-length"].to_i 63 | expected_content_length = token_params.fetch(:content_length) 64 | if actual_content_length != expected_content_length 65 | raise InvalidParams, "content-length mismatch, expecting upload of #{expected_content_length} bytes but sent #{actual_content_length}" 66 | end 67 | 68 | # Recover the encryption key from the headers (similar to how cloud storage services do it) 69 | b64_encryption_key = request.headers["x-active-storage-encryption-key"] 70 | raise InvalidParams, "x-active-storage-encryption-key header is missing" if b64_encryption_key.blank? 71 | encryption_key = Base64.strict_decode64(b64_encryption_key) 72 | 73 | # Verify the SHA of the encryption key 74 | encryption_key_b64sha = Digest::SHA256.base64digest(encryption_key) 75 | raise InvalidParams, "Incorrect checksum for the encryption key" unless Rack::Utils.secure_compare(encryption_key_b64sha, token_params.fetch(:encryption_key_sha256)) 76 | 77 | # Verify the Content-MD5 78 | b64_md5_from_headers = request.headers["content-md5"] 79 | raise InvalidParams, "Content-MD5 header is required" if b64_md5_from_headers.blank? 80 | raise InvalidParams, "Content-MD5 differs from the known checksum" unless Rack::Utils.secure_compare(b64_md5_from_headers, token_params.fetch(:checksum)) 81 | 82 | { 83 | key: token_params.fetch(:key), 84 | encryption_key: encryption_key, 85 | service_name: token_params.fetch(:service_name), 86 | checksum: token_params[:checksum], 87 | content_type: token_params.fetch(:content_type), 88 | content_length: token_params.fetch(:content_length) 89 | } 90 | end 91 | 92 | def lookup_service(name) 93 | service = ActiveStorage::Blob.services.fetch(name) { ActiveStorage::Blob.service } 94 | raise InvalidParams, "#{service.name} is not providing file encryption" unless service.try(:encrypted?) 95 | service 96 | end 97 | 98 | def blob_args 99 | params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, :service_name, metadata: {}).to_h.symbolize_keys 100 | end 101 | 102 | def service_name_from_params_or_config 103 | params[:service_name] || ActiveStorage::Blob.service.name # ? Rails.application.config.active_storage.service.name 104 | end 105 | 106 | def direct_upload_json(blob) 107 | blob.as_json(root: false, methods: :signed_id).merge(direct_upload: { 108 | url: blob.service_url_for_direct_upload, 109 | headers: blob.service_headers_for_direct_upload 110 | }) 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_disk_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "block_cipher_kit" 4 | require "active_storage/service/disk_service" 5 | 6 | module ActiveStorageEncryption 7 | # Provides a local encrypted store for ActiveStorage blobs. 8 | # Configure it like so: 9 | # 10 | # local_encrypted: 11 | # service: EncryptedDisk 12 | # root: <%= Rails.root.join("storage/encrypted") %> 13 | # private_url_policy: stream 14 | class EncryptedDiskService < ::ActiveStorage::Service::DiskService 15 | include ActiveStorageEncryption::PrivateUrlPolicy 16 | 17 | autoload :V1Scheme, __dir__ + "/encrypted_disk_service/v1_scheme.rb" 18 | autoload :V2Scheme, __dir__ + "/encrypted_disk_service/v2_scheme.rb" 19 | 20 | FILENAME_EXTENSIONS_PER_SCHEME = { 21 | ".encrypted-v1" => "V1Scheme", 22 | ".encrypted-v2" => "V2Scheme" 23 | } 24 | 25 | # This lets the Blob encryption key methods know that this 26 | # storage service _must_ use encryption 27 | def encrypted? = true 28 | 29 | def initialize(public: false, **options_for_disk_storage) 30 | raise ArgumentError, "encrypted files cannot be served via a public URL or a CDN" if public 31 | super 32 | end 33 | 34 | def upload(key, io, encryption_key:, checksum: nil, **) 35 | instrument :upload, key: key, checksum: checksum do 36 | scheme = create_scheme(key, encryption_key) 37 | File.open(make_path_for(key), "wb") do |file| 38 | scheme.streaming_encrypt(from_plaintext_io: io, into_ciphertext_io: file) 39 | end 40 | ensure_integrity_of(key, checksum, encryption_key) if checksum 41 | end 42 | end 43 | 44 | def download(key, encryption_key:, &block) 45 | if block_given? 46 | instrument :streaming_download, key: key do 47 | stream key, encryption_key, &block 48 | end 49 | else 50 | instrument :download, key: key do 51 | (+"").b.tap do |buf| 52 | download(key, encryption_key: encryption_key) do |data| 53 | buf << data 54 | end 55 | end 56 | end 57 | end 58 | end 59 | 60 | def download_chunk(key, range, encryption_key:) 61 | instrument :download_chunk, key: key, range: range do 62 | scheme = create_scheme(key, encryption_key) 63 | File.open(path_for(key), "rb") do |file| 64 | scheme.decrypt_range(from_ciphertext_io: file, range:) 65 | end 66 | rescue Errno::ENOENT 67 | raise ActiveStorage::FileNotFoundError 68 | end 69 | end 70 | 71 | def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, encryption_key:, custom_metadata: {}) 72 | instrument :url, key: key do |payload| 73 | upload_token = ActiveStorage.verifier.generate( 74 | { 75 | key: key, 76 | content_type: content_type, 77 | content_length: content_length, 78 | encryption_key_sha256: Digest::SHA256.base64digest(encryption_key), 79 | checksum: checksum, 80 | service_name: name 81 | }, 82 | expires_in: expires_in, 83 | purpose: :encrypted_put 84 | ) 85 | 86 | url_helpers = ActiveStorageEncryption::Engine.routes.url_helpers 87 | url_helpers.encrypted_blob_put_url(upload_token, url_options).tap do |generated_url| 88 | payload[:url] = generated_url 89 | end 90 | end 91 | end 92 | 93 | def path_for(key) # :nodoc: 94 | # The extension indicates what encryption scheme the file will be using. This method 95 | # gets used two ways - to get a path for a new object, and to get a path for an existing object. 96 | # If an existing object is found, we need to return the path for the highest version of that 97 | # object. If we want to create one - we always return the latest one. 98 | glob_pattern = File.join(root, folder_for(key), key + ".encrypted-*") 99 | last_existing_path = Dir.glob(glob_pattern).max 100 | path_for_new_file = File.join(root, folder_for(key), key + FILENAME_EXTENSIONS_PER_SCHEME.keys.last) 101 | last_existing_path || path_for_new_file 102 | end 103 | 104 | def exist?(key) 105 | File.exist?(path_for(key)) 106 | end 107 | 108 | def compose(source_keys, destination_key, source_encryption_keys:, encryption_key:, **) 109 | if source_keys.length != source_encryption_keys.length 110 | raise ArgumentError, "With #{source_keys.length} keys to compose there should be exactly as many source_encryption_keys, but got #{source_encryption_keys.length}" 111 | end 112 | File.open(make_path_for(destination_key), "wb") do |destination_file| 113 | writing_scheme = create_scheme(destination_key, encryption_key) 114 | writing_scheme.streaming_encrypt(into_ciphertext_io: destination_file) do |writable| 115 | source_keys.zip(source_encryption_keys).each do |(source_key, encryption_key_for_source)| 116 | File.open(path_for(source_key), "rb") do |source_file| 117 | reading_scheme = create_scheme(source_key, encryption_key_for_source) 118 | reading_scheme.streaming_decrypt(from_ciphertext_io: source_file, into_plaintext_io: writable) 119 | end 120 | end 121 | end 122 | end 123 | end 124 | 125 | def headers_for_direct_upload(key, content_type:, encryption_key:, checksum:, **) 126 | # Both GCP and AWS require the key to be provided in the headers, together with the 127 | # upload PUT request. This is not needed for the encrypted disk service, but it is 128 | # useful to check it does get passed to the HTTP client and then to the upload - 129 | # our controller extension will verify that this header is present, and fail if 130 | # it is not in place. 131 | super.merge!("x-active-storage-encryption-key" => Base64.strict_encode64(encryption_key), "content-md5" => checksum) 132 | end 133 | 134 | def headers_for_private_download(key, encryption_key:, **) 135 | {"x-active-storage-encryption-key" => Base64.strict_encode64(encryption_key)} 136 | end 137 | 138 | private 139 | 140 | def create_scheme(key, encryption_key_from_blob) 141 | # Check whether this blob already exists and which version it is. 142 | # path_for_key will give us the path to the existing version. 143 | filename_extension = File.extname(path_for(key)) 144 | scheme_class_name = FILENAME_EXTENSIONS_PER_SCHEME.fetch(filename_extension) 145 | scheme_class = self.class.const_get(scheme_class_name) 146 | scheme_class.new(encryption_key_from_blob.b) 147 | end 148 | 149 | def private_url(key, **options) 150 | private_url_for_streaming_via_controller(key, **options) 151 | end 152 | 153 | def public_url(key, filename:, encryption_key:, content_type: nil, disposition: :attachment, **) 154 | raise "This should never be called" 155 | end 156 | 157 | def stream(key, encryption_key, &blk) 158 | scheme = create_scheme(key, encryption_key) 159 | File.open(path_for(key), "rb") do |file| 160 | scheme.streaming_decrypt(from_ciphertext_io: file, &blk) 161 | end 162 | rescue Errno::ENOENT 163 | raise ActiveStorage::FileNotFoundError 164 | end 165 | 166 | def ensure_integrity_of(key, checksum, encryption_key) 167 | digest = OpenSSL::Digest.new("MD5") 168 | stream(key, encryption_key) do |decrypted_data| 169 | digest << decrypted_data 170 | end 171 | unless digest.base64digest == checksum 172 | delete key 173 | raise ActiveStorage::IntegrityError 174 | end 175 | end 176 | 177 | def service_name 178 | # Normally: ActiveStorage::Service::DiskService => Disk, so it does 179 | # a split on "::" on the class name etc. Even though this is private, 180 | # it does get called from the outside (or by other ActiveStorage::Service methods). 181 | # Oddly it does _not_ get used in the `ActiveStorage::Configurator` to resolve 182 | # the class to use. 183 | "EncryptedDisk" 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_disk_service/v1_scheme.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveStorageEncryption::EncryptedDiskService::V1Scheme 4 | def initialize(encryption_key) 5 | @scheme = BlockCipherKit::AES256CFBCIVScheme.new(encryption_key) 6 | @key_digest = Digest::SHA256.digest(encryption_key.byteslice(0, 16 + 32)) # In this scheme the IV is suffixed with the key 7 | end 8 | 9 | def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk) 10 | validate_key!(from_ciphertext_io) 11 | @scheme.streaming_decrypt(from_ciphertext_io:, into_plaintext_io:, &blk) 12 | end 13 | 14 | def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk) 15 | into_ciphertext_io.write(@key_digest) 16 | @scheme.streaming_encrypt(into_ciphertext_io:, from_plaintext_io:, &blk) 17 | end 18 | 19 | def decrypt_range(from_ciphertext_io:, range:) 20 | validate_key!(from_ciphertext_io) 21 | @scheme.decrypt_range(from_ciphertext_io:, range:) 22 | end 23 | 24 | def validate_key!(io) 25 | key_digest_from_io = io.read(@key_digest.bytesize) 26 | raise ActiveStorageEncryption::IncorrectEncryptionKey unless key_digest_from_io == @key_digest 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_disk_service/v2_scheme.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This scheme uses GCM encryption with CTR-based random access. The auth tag is stored 4 | # at the end of the message. The message is prefixed by a SHA2 digest of the encryption key. 5 | class ActiveStorageEncryption::EncryptedDiskService::V2Scheme 6 | def initialize(encryption_key) 7 | @scheme = BlockCipherKit::AES256GCMScheme.new(encryption_key) 8 | @key_digest = Digest::SHA256.digest(encryption_key.byteslice(0, 32)) # In this scheme just the key is used 9 | end 10 | 11 | def streaming_decrypt(from_ciphertext_io:, into_plaintext_io: nil, &blk) 12 | check_key!(from_ciphertext_io) 13 | @scheme.streaming_decrypt(from_ciphertext_io:, into_plaintext_io:, &blk) 14 | end 15 | 16 | def streaming_encrypt(into_ciphertext_io:, from_plaintext_io: nil, &blk) 17 | # See check_key! for rationale. We need a fast KVC (key validation code) 18 | # to refuse the download if we know the key is incorrect. 19 | into_ciphertext_io.write(@key_digest) 20 | @scheme.streaming_encrypt(into_ciphertext_io:, from_plaintext_io:, &blk) 21 | end 22 | 23 | def decrypt_range(from_ciphertext_io:, range:) 24 | check_key!(from_ciphertext_io) 25 | @scheme.decrypt_range(from_ciphertext_io:, range:) 26 | end 27 | 28 | private def check_key!(io) 29 | # We need a fast KCV (key check value) to refuse the download 30 | # if we know the key is incorrect. We can't use the auth tag from GCM 31 | # because it can only be computed if the entirety of the ciphertext has been read by the 32 | # cipher - and we want random access. We could use a HMAC(encryption_key, auth_tag) at the 33 | # tail of ciphertext to achieve the same, but that would require streaming_decrypt to seek inside 34 | # the ciphertext IO to read the tail of the file - which we don't want to require. 35 | # 36 | # Besides, we want to not tie up server resources if we know 37 | # that the furnished encryption key is incorrect. So: a KVC. 38 | # 39 | # We store the SHA2 value of the encryption key at the start of the ciphertext. We assume that the encryption 40 | # key will be generated randomly and will be very high-entropy, so the only attack strategy for it is brute-force. 41 | # Brute-force is keyspace / hashrate, as explained here: https://stackoverflow.com/questions/4764026/how-many-sha256-hashes-can-a-modern-computer-compute 42 | # which, for our key of 32 bytes, gives us this calculation to find out the number of years to crack this SHA on 43 | # a GeForce 2080Ti (based on https://hashcat.net/forum/thread-10185.html): 44 | # ((256 ** 32) / (7173 * 1000 * 1000)) / 60 / 60 / 24 / 365 45 | # which is 46 | # 511883878862512581460395486615240253212171357229849212045742 47 | # This is quite some years. So storing the digest of the key is reasonably safe. 48 | key_digest_from_io = io.read(@key_digest.bytesize) 49 | raise ActiveStorageEncryption::IncorrectEncryptionKey unless key_digest_from_io == @key_digest 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_gcs_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage/service/gcs_service" 4 | require "google/cloud/storage/service" 5 | 6 | class ActiveStorageEncryption::EncryptedGCSService < ActiveStorage::Service::GCSService 7 | include ActiveStorageEncryption::PrivateUrlPolicy 8 | GCS_ENCRYPTION_KEY_LENGTH_BYTES = 32 # google wants to get a 32 byte key 9 | 10 | def encrypted? = true 11 | 12 | def public? = false 13 | 14 | def service_name 15 | # ActiveStorage::Service::DiskService => Disk 16 | # Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")" 17 | self.class.name.split("::").last.remove("Service") 18 | end 19 | 20 | def upload(key, io, encryption_key: nil, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {}) 21 | instrument :upload, key: key, checksum: checksum do 22 | # GCS's signed URLs don't include params such as response-content-type response-content_disposition 23 | # in the signature, which means an attacker can modify them and bypass our effort to force these to 24 | # binary and attachment when the file's content type requires it. The only way to force them is to 25 | # store them as object's metadata. 26 | content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename 27 | bucket.create_file(io, key, md5: checksum, cache_control: @config[:cache_control], content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata, encryption_key: derive_service_encryption_key(encryption_key)) 28 | rescue Google::Cloud::InvalidArgumentError => e 29 | raise ActiveStorage::IntegrityError, e 30 | end 31 | end 32 | 33 | def url_for_direct_upload(key, expires_in:, checksum:, encryption_key:, content_type: nil, custom_metadata: {}, filename: nil, **) 34 | instrument :url, key: key do |payload| 35 | headers = headers_for_direct_upload(key, checksum:, encryption_key:, content_type:, filename:, custom_metadata:) 36 | 37 | version = :v4 38 | 39 | args = { 40 | content_md5: checksum, 41 | expires: expires_in, 42 | headers: headers, 43 | method: "PUT", 44 | version: version 45 | } 46 | 47 | if @config[:iam] 48 | args[:issuer] = issuer 49 | args[:signer] = signer 50 | end 51 | 52 | generated_url = bucket.signed_url(key, **args) 53 | 54 | payload[:url] = generated_url 55 | 56 | generated_url 57 | end 58 | end 59 | 60 | def headers_for_direct_upload(key, checksum:, encryption_key:, filename: nil, disposition: nil, content_type: nil, custom_metadata: {}, **) 61 | headers = { 62 | "Content-Type" => content_type, 63 | "Content-MD5" => checksum, # Not strictly required, but it ensures the file bytes we upload match what we want. This way google will error when we upload garbage. 64 | **gcs_encryption_key_headers(derive_service_encryption_key(encryption_key)), 65 | **custom_metadata_headers(custom_metadata) 66 | } 67 | headers["Content-Disposition"] = content_disposition_with(type: disposition, filename: filename) if filename 68 | 69 | if @config[:cache_control].present? 70 | headers["Cache-Control"] = @config[:cache_control] 71 | end 72 | headers 73 | end 74 | 75 | def download(key, encryption_key: nil, &block) 76 | if block_given? 77 | instrument :streaming_download, key: key do 78 | stream(key, encryption_key: encryption_key, &block) 79 | end 80 | else 81 | instrument :download, key: key do 82 | file_for(key).download(encryption_key: derive_service_encryption_key(encryption_key)).string 83 | rescue Google::Cloud::NotFoundError => e 84 | raise ActiveStorage::FileNotFoundError, e 85 | end 86 | end 87 | end 88 | 89 | def download_chunk(key, range, encryption_key: nil) 90 | instrument :download_chunk, key: key, range: range do 91 | file_for(key).download(range: range, encryption_key: derive_service_encryption_key(encryption_key)).string 92 | rescue Google::Cloud::NotFoundError => e 93 | raise ActiveStorage::FileNotFoundError, e 94 | end 95 | end 96 | 97 | # Reads the file for the given key in chunks, yielding each to the block. 98 | def stream(key, encryption_key: nil) 99 | file = file_for(key, skip_lookup: false) 100 | 101 | chunk_size = 5.megabytes 102 | offset = 0 103 | 104 | raise ActiveStorage::FileNotFoundError unless file.present? 105 | 106 | while offset < file.size 107 | yield file.download(range: offset..(offset + chunk_size - 1), encryption_key: derive_service_encryption_key(encryption_key)).string 108 | offset += chunk_size 109 | end 110 | end 111 | 112 | def compose(source_keys, destination_key, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) 113 | # Because we will always have a different encryption_key on a blob when created and google requires us to have the same encryption_keys on all source blobs 114 | # we need to work this out a bit more. For now we don't need this and thus won't support it in this service. 115 | raise NotImplementedError, "Currently composing files is not supported" 116 | end 117 | 118 | private 119 | 120 | def private_url(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url) 121 | if private_url_policy == :require_headers 122 | args = { 123 | expires: expires_in, 124 | query: { 125 | "response-content-disposition" => content_disposition_with(type: disposition, filename: filename), 126 | "response-content-type" => content_type 127 | }, 128 | headers: gcs_encryption_key_headers(derive_service_encryption_key(encryption_key)) 129 | } 130 | 131 | if @config[:iam] 132 | args[:issuer] = issuer 133 | args[:signer] = signer 134 | end 135 | 136 | file_for(key).signed_url(**args, version: :v4) 137 | else 138 | private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, **remaining_options_for_streaming_url) 139 | end 140 | end 141 | 142 | def public_url(key, filename:, encryption_key:, content_type: nil, disposition: :inline, **) 143 | raise "Public URL's are disabled for this service" 144 | end 145 | 146 | def gcs_encryption_key_headers(key) 147 | { 148 | "x-goog-encryption-algorithm" => "AES256", 149 | "x-goog-encryption-key" => Base64.strict_encode64(key), 150 | "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(key) 151 | } 152 | end 153 | 154 | def derive_service_encryption_key(blob_encryption_key) 155 | raise ArgumentError, "The blob encryption_key must be at least #{GCS_ENCRYPTION_KEY_LENGTH_BYTES} bytes long" unless blob_encryption_key.bytesize >= GCS_ENCRYPTION_KEY_LENGTH_BYTES 156 | blob_encryption_key[0...GCS_ENCRYPTION_KEY_LENGTH_BYTES] 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_mirror_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage/service/mirror_service" 4 | 5 | class ActiveStorageEncryption::EncryptedMirrorService < ActiveStorage::Service::MirrorService 6 | delegate :private_url_policy, to: :primary 7 | 8 | class MirrorJobWithEncryption < ActiveStorage::MirrorJob 9 | def perform(key, checksum:, service_name:, encryption_key_token:) 10 | service = lookup_service(service_name) 11 | service.try(:mirror_with_encryption, key, checksum: checksum, encryption_key: encryption_key_from_token(encryption_key_token)) 12 | end 13 | 14 | def encryption_key_from_token(encryption_key_token) 15 | decrypted_token = ActiveStorageEncryption.token_encryptor.decrypt_and_verify(encryption_key_token, purpose: :mirror) 16 | Base64.decode64(decrypted_token.fetch("encryption_key")) 17 | end 18 | 19 | def lookup_service(name) 20 | # This should be the name in the config, NOT the class name 21 | service = ActiveStorage::Blob.services.fetch(name) { ActiveStorage::Blob.service } 22 | raise ArgumentError, "#{service.name} is not providing file encryption" unless service.try(:encrypted?) 23 | service 24 | end 25 | end 26 | 27 | def private_url_policy=(_) 28 | raise ArgumentError, "EncryptedMirrorService uses the private_url_policy of the primary" 29 | end 30 | 31 | def encrypted? 32 | true 33 | end 34 | 35 | def upload(key, io, encryption_key:, checksum: nil, **options) 36 | io.rewind 37 | if primary.try(:encrypted?) 38 | primary.upload(key, io, checksum: checksum, encryption_key: encryption_key, **options) 39 | else 40 | primary.upload(key, io, checksum: checksum, **options) 41 | end 42 | mirror_later_with_encryption(key, checksum: checksum, encryption_key: encryption_key, **options) 43 | end 44 | 45 | def mirror_with_encryption(key, checksum:, encryption_key:) 46 | instrument :mirror, key: key, checksum: checksum do 47 | mirrors_in_need_of_mirroring = mirrors.select { |service| !service.exist?(key) } 48 | return if mirrors_in_need_of_mirroring.empty? 49 | primary.open(key, checksum: checksum, verify: checksum.present?, encryption_key: encryption_key) do |io| 50 | mirrors_in_need_of_mirroring.each do |target| 51 | io.rewind 52 | options = target.try(:encrypted?) ? {encryption_key: encryption_key} : {} 53 | target.upload(key, io, checksum: checksum, **options) 54 | end 55 | end 56 | end 57 | end 58 | 59 | def service_name 60 | # ActiveStorage::Service::DiskService => Disk 61 | # Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")" 62 | self.class.name.split("::").last.remove("Service") 63 | end 64 | 65 | private 66 | 67 | def mirror_later_with_encryption(key, checksum:, encryption_key: nil) 68 | encryption_key_token = ActiveStorageEncryption.token_encryptor.encrypt_and_sign( 69 | { 70 | encryption_key: Base64.strict_encode64(encryption_key) 71 | }, 72 | purpose: :mirror 73 | ) 74 | MirrorJobWithEncryption.perform_later(key, checksum: checksum, service_name:, encryption_key_token:) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/encrypted_s3_service.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_storage/service/s3_service" 4 | 5 | class ActiveStorageEncryption::EncryptedS3Service < ActiveStorage::Service::S3Service 6 | include ActiveStorageEncryption::PrivateUrlPolicy 7 | def encrypted? = true 8 | 9 | def initialize(public: false, **options_for_s3_service_and_private_url_policy) 10 | raise ArgumentError, "encrypted files cannot be served via a public URL or a CDN" if public 11 | super 12 | end 13 | 14 | def service_name 15 | # ActiveStorage::Service::DiskService => Disk 16 | # Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")" 17 | self.class.name.split("::").last.remove("Service") 18 | end 19 | 20 | def headers_for_direct_upload(key, encryption_key:, **options_for_super) 21 | # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html#specifying-s3-c-encryption 22 | # This is the same as sse_options but expressed with raw header names 23 | sdk_sse_options = sse_options(encryption_key) 24 | super(key, **options_for_super).merge!({ 25 | "x-amz-server-side-encryption-customer-key" => Base64.strict_encode64(sdk_sse_options.fetch(:sse_customer_key)), 26 | "x-amz-server-side-encryption-customer-key-MD5" => Digest::MD5.base64digest(sdk_sse_options.fetch(:sse_customer_key)) 27 | }) 28 | end 29 | 30 | def exist?(key) 31 | # The stock S3Service uses S3::Object#exists? here. That method does 32 | # a HEAD request to the S3 bucket under the hood. But there is a problem 33 | # with that approach: to get all the metadata attributes of an object on S3 34 | # (which is what the HEAD request should return to you) you need the encryption key. 35 | # The interface of the ActiveStorage services does not provide for extra arguments 36 | # for `Service#exist?`, so all we would get using that SDK call would be an error. 37 | # 38 | # But we don't need the object metadata - we need to know is whether the object exists 39 | # at all. And this can be done with a GET request instead. We ask S3 to give us the first byte of the 40 | # object. S3 will then raise an exception - the exception will be different 41 | # depending on whether the object does not exist _or_ the object does exist, but 42 | # is encrypted. We can use the distinction between those exceptions to tell 43 | # whether the object is there or not. 44 | # 45 | # There is also a case where the object is not encrypted - in that situation 46 | # our single-byte GET request will actually succeed. This also means that the 47 | # object exists in the bucket. 48 | object_for(key).get(range: "bytes=0-0") 49 | # If we get here without an exception - the object exists in the bucket, 50 | # but is not encrypted. For example, it was stored using a stock S3Service. 51 | true 52 | rescue Aws::S3::Errors::InvalidRequest 53 | # With this exception S3 tells us that the object exists but we have to furnish 54 | # the encryption key (the exception will have a message with "object was stored 55 | # using a form of Server Side Encryption..."). 56 | true 57 | rescue Aws::S3::Errors::NoSuchKey 58 | # And this truly means the object is not present 59 | false 60 | end 61 | 62 | def headers_for_private_download(key, encryption_key:, **) 63 | sdk_sse_options = sse_options(encryption_key) 64 | { 65 | "x-amz-server-side-encryption-customer-key" => Base64.strict_encode64(sdk_sse_options.fetch(:sse_customer_key)) 66 | } 67 | end 68 | 69 | def url_for_direct_upload(key, encryption_key:, **options_for_super) 70 | # With direct upload we need to remove the encryption key itself from 71 | # the SDK parameters. Otherwise it does get included in the URL, but that 72 | # does not make S3 actually _use_ the value - _and_ it leaks the key. 73 | # We _do_ need the key MD5 to be in the signed header params, so that the client can't use an encryption key 74 | # it invents by itself - it must use the one we issue it. 75 | sse_options_without_key = sse_options(encryption_key).without(:sse_customer_key) 76 | with_upload_options_for_customer_key(sse_options_without_key) do 77 | super(key, **options_for_super) 78 | end 79 | end 80 | 81 | def upload(*args, encryption_key:, **kwargs) 82 | with_upload_options_for_customer_key(sse_options(encryption_key)) do 83 | super(*args, **kwargs) 84 | end 85 | end 86 | 87 | def download(key, encryption_key:, &block) 88 | if block_given? 89 | instrument :streaming_download, key: key do 90 | stream(key, encryption_key: encryption_key, &block) 91 | end 92 | else 93 | instrument :download, key: key do 94 | object_for(key).get(**sse_options(encryption_key)).body.string.force_encoding(Encoding::BINARY) 95 | rescue Aws::S3::Errors::NoSuchKey 96 | raise ActiveStorage::FileNotFoundError 97 | end 98 | end 99 | end 100 | 101 | def download_chunk(key, range, encryption_key:) 102 | instrument :download_chunk, key: key, range: range do 103 | object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}", **sse_options(encryption_key)).body.string.force_encoding(Encoding::BINARY) 104 | rescue Aws::S3::Errors::NoSuchKey 105 | raise ActiveStorage::FileNotFoundError 106 | end 107 | end 108 | 109 | def compose(source_keys, destination_key, source_encryption_keys:, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) 110 | if source_keys.length != source_encryption_keys.length 111 | raise ArgumentError, "With #{source_keys.length} keys to compose there should be exactly as many source_encryption_keys, but got #{source_encryption_keys.length}" 112 | end 113 | content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename 114 | upload_options_for_compose = upload_options.merge(sse_options(encryption_key)) 115 | object_for(destination_key).upload_stream( 116 | content_type: content_type, 117 | content_disposition: content_disposition, 118 | part_size: MINIMUM_UPLOAD_PART_SIZE, 119 | metadata: custom_metadata, 120 | **upload_options_for_compose 121 | ) do |s3_multipart_io| 122 | s3_multipart_io.binmode 123 | source_keys.zip(source_encryption_keys).each do |(source_key, source_encryption_key)| 124 | stream(source_key, encryption_key: source_encryption_key) do |chunk| 125 | s3_multipart_io.write(chunk) 126 | end 127 | end 128 | end 129 | end 130 | 131 | private 132 | 133 | # Reads the object for the given key in chunks, yielding each to the block. 134 | def stream(key, encryption_key:) 135 | object = object_for(key) 136 | 137 | chunk_size = 5.megabytes 138 | offset = 0 139 | 140 | # Doing a HEAD (what .exists? does under the hood) also requires the encryption key headers, 141 | # but the SDK does not send them along. Instead of doing a HEAD, you can also do a GET - but for the first byte. 142 | # This will give you the content-length of the object, and the SDK will pass the correct encryption headers. 143 | # There is an issue in the SDK here https://github.com/aws/aws-sdk-ruby/issues/1342 which is allegedly fixed 144 | # by https://github.com/aws/aws-sdk-ruby/pull/1343/files but it doesn't seem like it. 145 | # Also, we do not only call `S3::Object#exists?`, but also `S3::Object#content_length` - which does not have a way to pass 146 | # encryption options either. 147 | response = object.get(range: "bytes=0-0", **sse_options(encryption_key)) 148 | object_content_length = response.content_range.scan(/\d+$/).first.to_i 149 | 150 | while offset < object_content_length 151 | yield object.get(range: "bytes=#{offset}-#{offset + chunk_size - 1}", **sse_options(encryption_key)).body.string.force_encoding(Encoding::BINARY) 152 | offset += chunk_size 153 | end 154 | rescue Aws::S3::Errors::NoSuchKey 155 | raise ActiveStorage::FileNotFoundError 156 | end 157 | 158 | def sse_options(encryption_key) 159 | truncated_key_bytes = encryption_key.byteslice(0, 32) 160 | { 161 | sse_customer_algorithm: "AES256", 162 | sse_customer_key: truncated_key_bytes, 163 | sse_customer_key_md5: Digest::MD5.base64digest(truncated_key_bytes) 164 | } 165 | end 166 | 167 | def private_url(key, encryption_key:, **options) 168 | case private_url_policy 169 | when :disable 170 | if private_url_policy == :disable 171 | raise ActiveStorageEncryption::StreamingDisabled, <<~EOS 172 | Requested a signed GET URL for #{key.inspect} on service #{name}. This service 173 | has disabled presigned URLs (private_url_policy: disable), you have to use `Blob#download` instead. 174 | EOS 175 | end 176 | when :stream 177 | private_url_for_streaming_via_controller(key, encryption_key:, **options) 178 | when :require_headers 179 | sse_options_for_presigned_url = sse_options(encryption_key) 180 | 181 | # Remove the key itself. If we pass it to the SDK - it will leak the key (the key will be in the URL), 182 | # but the download will still fail. 183 | sse_options_for_presigned_url.delete(:sse_customer_key) 184 | 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 187 | super(key, **options_for_super) 188 | end 189 | end 190 | 191 | def public_url(key, **client_opts) 192 | raise "This should never be called" 193 | end 194 | 195 | def upload_options 196 | super.merge(Thread.current[:aws_sse_options].to_h) 197 | end 198 | 199 | def with_upload_options_for_customer_key(overriding_upload_options) 200 | # Gotta be careful here, because this call can be re-entrant. 201 | # If one thread calls `upload_options` to do an upload, and does not 202 | # return for some time, we want this thread to be using the upload options 203 | # reserved for it - otherwise objects can get not their encryption keys, but 204 | # others'. If we want to have upload_options be tailored to every specific upload, 205 | # we would need to override way more of this Service class than is really needed. 206 | # You can actually see that sometimes there is reentrancy here: 207 | # 208 | # MUX = Mutex.new 209 | # opens_before = MUX.synchronize { @opens ||= 0; @opens += 1; @opens - 1 } 210 | previous = Thread.current[:aws_sse_options] 211 | Thread.current[:aws_sse_options] = overriding_upload_options 212 | yield 213 | ensure 214 | # To check that there is reentrancy: 215 | # opens_after = MUX.synchronize { @opens -= 1 } 216 | # warn [opens_before, opens_after].inspect #exiting wo" 217 | # In our tests: 218 | # [2, 11] 219 | # [10, 10] 220 | # [0, 9] 221 | # [9, 8] 222 | # [5, 7] 223 | # [3, 6] 224 | # [6, 5] 225 | # [1, 4] 226 | # [8, 3] 227 | # [4, 2] 228 | # [7, 1] 229 | # [11, 0] 230 | # [0, 0] 231 | # [0, 0] 232 | # [0, 0] 233 | # [0, 0] 234 | # [0, 0] 235 | Thread.current[:aws_sse_options] = previous 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveStorageEncryption 4 | class Engine < ::Rails::Engine 5 | isolate_namespace ActiveStorageEncryption 6 | 7 | generators do 8 | require "generators/install_generator" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/overrides.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveStorageEncryption 4 | module Overrides 5 | class EncryptionKeyMissingError < StandardError 6 | end 7 | 8 | module EncryptedBlobClassMethods 9 | def self.included base 10 | base.class_eval do 11 | encrypts :encryption_key 12 | validates :encryption_key, presence: {message: "must be present for this service"}, if: :service_encrypted? 13 | 14 | class << self 15 | ENCRYPTION_KEY_LENGTH_BYTES = 16 + 32 # So we have enough 16 | 17 | def service_encrypted?(service_name) 18 | return false unless service_name 19 | 20 | service = ActiveStorage::Blob.services.fetch(service_name) do 21 | ActiveStorage::Blob.service 22 | end 23 | 24 | !!service&.try(:encrypted?) 25 | end 26 | 27 | def generate_random_encryption_key 28 | SecureRandom.bytes(ENCRYPTION_KEY_LENGTH_BYTES) 29 | end 30 | 31 | def create_before_direct_upload!(filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil, key: nil, encryption_key: nil) 32 | encryption_key = service_encrypted?(service_name) ? (encryption_key || generate_random_encryption_key) : nil 33 | create!(key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name, encryption_key: encryption_key) 34 | end 35 | 36 | def create_and_upload!(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil, key: nil, encryption_key: nil) 37 | create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, encryption_key:).tap do |blob| 38 | blob.upload_without_unfurling(io) 39 | end 40 | end 41 | 42 | def build_after_unfurling(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil, key: nil, encryption_key: nil) 43 | new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, encryption_key:).tap do |blob| 44 | blob.unfurl(io, identify: identify) 45 | blob.encryption_key ||= service_encrypted?(service_name) ? (encryption_key || generate_random_encryption_key) : nil 46 | end 47 | end 48 | 49 | def create_after_unfurling!(io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil, key: nil, encryption_key: nil) 50 | build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, encryption_key:).tap(&:save!) 51 | end 52 | 53 | # Concatenate multiple blobs into a single "composed" blob. 54 | def compose(blobs, filename:, content_type: nil, metadata: nil, key: nil, service_name: nil, encryption_key: nil) 55 | raise ActiveRecord::RecordNotSaved, "All blobs must be persisted." if blobs.any?(&:new_record?) 56 | 57 | content_type ||= blobs.pluck(:content_type).compact.first 58 | 59 | new(key: key, filename: filename, content_type: content_type, metadata: metadata, byte_size: blobs.sum(&:byte_size), service_name:, encryption_key:).tap do |combined_blob| 60 | combined_blob.compose(blobs.pluck(:key), source_encryption_keys: blobs.pluck(:encryption_key)) 61 | combined_blob.save! 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | 69 | module EncryptedBlobInstanceMethods 70 | def service_encrypted? 71 | !!service&.try(:encrypted?) 72 | end 73 | 74 | def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in) 75 | if service_encrypted? 76 | ensure_encryption_key_set! 77 | 78 | service.url_for_direct_upload(key, expires_in: expires_in, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key) 79 | else 80 | super 81 | end 82 | end 83 | 84 | def open(tmpdir: nil, &block) 85 | ensure_encryption_key_set! if service_encrypted? 86 | 87 | service.open( 88 | key, 89 | encryption_key: encryption_key, 90 | checksum: checksum, 91 | verify: !composed, 92 | name: ["ActiveStorage-#{id}-", filename.extension_with_delimiter], 93 | tmpdir: tmpdir, 94 | &block 95 | ) 96 | end 97 | 98 | def service_headers_for_direct_upload 99 | if service_encrypted? 100 | ensure_encryption_key_set! 101 | 102 | service.headers_for_direct_upload(key, filename: filename, content_type: content_type, content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata, encryption_key: encryption_key) 103 | else 104 | super 105 | end 106 | end 107 | 108 | def upload_without_unfurling(io) 109 | if service_encrypted? 110 | ensure_encryption_key_set! 111 | 112 | service.upload(key, io, checksum: checksum, encryption_key: encryption_key, **service_metadata) 113 | else 114 | super 115 | end 116 | end 117 | 118 | # Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned. 119 | # That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks. 120 | def download(&block) 121 | if service_encrypted? 122 | ensure_encryption_key_set! 123 | 124 | service.download(key, encryption_key: encryption_key, &block) 125 | else 126 | super 127 | end 128 | end 129 | 130 | def download_chunk(range) 131 | if service_encrypted? 132 | ensure_encryption_key_set! 133 | 134 | service.download_chunk(key, range, encryption_key: encryption_key) 135 | else 136 | super 137 | end 138 | end 139 | 140 | def compose(keys, source_encryption_keys: []) 141 | if service_encrypted? 142 | ensure_encryption_key_set! 143 | 144 | self.composed = true 145 | service.compose(keys, key, encryption_key: encryption_key, source_encryption_keys: source_encryption_keys, **service_metadata) 146 | else 147 | super(keys) 148 | end 149 | end 150 | 151 | def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options) 152 | if service_encrypted? 153 | ensure_encryption_key_set! 154 | 155 | service.url( 156 | key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename), 157 | encryption_key: encryption_key, 158 | content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, 159 | blob_byte_size: byte_size, 160 | **options 161 | ) 162 | else 163 | super 164 | end 165 | end 166 | 167 | # The encryption_key can be in binary and not serializabe to UTF-8 by to_json, thus we always want to 168 | # leave it out. This is also to better mimic how native ActiveStorage handles it. 169 | def serializable_hash(options = nil) 170 | options = if options 171 | options.merge(except: Array.wrap(options[:except]).concat([:encryption_key]).uniq) 172 | else 173 | {except: [:encryption_key]} 174 | end 175 | super 176 | end 177 | 178 | private 179 | 180 | def ensure_encryption_key_set! 181 | raise EncryptionKeyMissingError, "Encryption key must be present" unless encryption_key.present? 182 | end 183 | end 184 | 185 | module BlobIdentifiableInstanceMethods 186 | private 187 | 188 | # Active storage attach() tries to identify the content_type of the file. For that it downloads a chunk. 189 | # Since we have an encrypted disk service which needs an encryption_key on everything, every call to it needs the encryption_key passed too. 190 | def download_identifiable_chunk 191 | if service_encrypted? 192 | if byte_size.positive? 193 | service.download_chunk(key, 0...4.kilobytes, encryption_key: encryption_key) 194 | else 195 | "".b 196 | end 197 | else 198 | super 199 | end 200 | end 201 | end 202 | 203 | module DownloaderInstanceMethods 204 | def open(key, encryption_key: nil, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil, &blk) 205 | raise EncryptionKeyMissingError, "An encryption key must be supplied when using an encrypted service" if !encryption_key && service.respond_to?(:encrypted?) && service.encrypted? 206 | 207 | open_tempfile(name, tmpdir) do |file| 208 | download(key, file, encryption_key: encryption_key) 209 | verify_integrity_of(file, checksum: checksum) if verify 210 | yield file 211 | end 212 | end 213 | 214 | private 215 | 216 | def download(key, file, encryption_key: nil) 217 | if service.respond_to?(:encrypted?) && service.encrypted? 218 | raise "An encryption key must be supplied when using an encrypted service" unless encryption_key 219 | 220 | file.binmode 221 | service.download(key, encryption_key: encryption_key) { |chunk| file.write(chunk) } 222 | file.flush 223 | file.rewind 224 | else 225 | super(key, file) 226 | end 227 | end 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/private_url_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveStorageEncryption::PrivateUrlPolicy 4 | DEFAULT_POLICY = :stream 5 | 6 | def initialize(private_url_policy: DEFAULT_POLICY, **any_other_options_for_service) 7 | self.private_url_policy = private_url_policy.to_sym 8 | super(**any_other_options_for_service) 9 | end 10 | 11 | def private_url_policy=(new_value) 12 | allowed = [:disable, :require_headers, :stream] 13 | raise ArgumentError, "private_url_policy: must be one of #{allowed.join(",")}" unless allowed.include?(new_value.to_sym) 14 | @private_url_policy = new_value.to_sym 15 | end 16 | 17 | def private_url_policy 18 | @private_url_policy 19 | end 20 | 21 | def private_url_for_streaming_via_controller(key, expires_in:, filename:, content_type:, disposition:, encryption_key:, blob_byte_size:) 22 | if private_url_policy == :disable 23 | raise ActiveStorageEncryption::StreamingDisabled, <<~EOS 24 | Requested a signed GET URL for #{key.inspect} on service #{name}. This service 25 | has disabled presigned URLs (private_url_policy: disable), you have to use `Blob#download` instead. 26 | EOS 27 | end 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. 30 | content_disposition = content_disposition_with(type: disposition, filename: filename) 31 | verified_key_with_expiration = ActiveStorageEncryption.token_encryptor.encrypt_and_sign( 32 | { 33 | key: key, 34 | disposition: content_disposition, 35 | encryption_key_sha256: Digest::SHA256.base64digest(encryption_key), 36 | content_type: content_type, 37 | service_name: name, 38 | blob_byte_size: blob_byte_size, 39 | encryption_key: Base64.strict_encode64(encryption_key) 40 | }, 41 | expires_in: expires_in, 42 | purpose: :encrypted_get 43 | ) 44 | 45 | # Both url_helpers and url_options are on the DiskService, but we need them here for other Services too 46 | url_helpers = ActiveStorageEncryption::Engine.routes.url_helpers 47 | url_options = ActiveStorage::Current.url_options 48 | 49 | if url_options.blank? 50 | raise ArgumentError, "Cannot generate URL for #{filename} because ActiveStorage::Current.url_options is not set" 51 | end 52 | 53 | url_helpers.encrypted_blob_streaming_get_url(verified_key_with_expiration, filename: filename, **url_options) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/resumable_gcs_upload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Unlike the AWS SDKs, the Ruby GCP SDKs do not have a built-in resumable upload feature, while that 4 | # feature is well-supported by GCP (and has been supported for a long while). This module provides 5 | # resumable uploads in an IO-like package, giving you an object you can write to. 6 | # 7 | # file = @bucket.file("upload.bin", skip_lookup: true) 8 | # upload = ActiveStorageEncryption::ResumableGCSUpload.new(file) 9 | # upload.stream do |io| 10 | # io.write("Hello resumable") 11 | # 20.times { io.write(Random.bytes(1.megabyte)) } 12 | # end 13 | # 14 | # Note that to perform the resumable upload your IAM identity or machine identity must have either 15 | # a correct key for accessing Cloud Storage, or - alternatively - run under a service account 16 | # that is permitted to sign blobs. This maps to the "iam.serviceAccountTokenCreator" role - 17 | # see https://github.com/googleapis/google-cloud-ruby/issues/13307 and https://cloud.google.com/iam/docs/service-account-permissions 18 | class ActiveStorageEncryption::ResumableGCSUpload 19 | # AWS recommend 5MB as the default part size for multipart uploads. GCP recommend doing "less requests" 20 | # in general, and they mandate that all parts except last are a multile of 256*1024. Knowing that we will 21 | # need to hold a buffer of that size, let's just assume that the 5MB that AWS uses is a good number for part size. 22 | CHUNK_SIZE_FOR_UPLOADS = 5 * 1024 * 1024 23 | 24 | # When doing GCP uploads the chunks need to be sized to 256KB increments, and the output 25 | # that we generate is not guaranteed to be chopped up this way. Also the upload for the last 26 | # chunk is done slightly different than the preceding chunks. It is convenient to have a 27 | # way to "chop up" an arbitrary streaming output into evenly sized chunks. 28 | class ByteChunker 29 | # @param chunk_size[Integer] the chunk size that all the chunks except the last one must have 30 | # @delivery_proc the proc that will receive the bytes and the `is_last` boolean to indicate the last chunk 31 | def initialize(chunk_size: 256 * 1024, &delivery_proc) 32 | @chunk_size = chunk_size.to_i 33 | # Use a fixed-capacity String instead of a StringIO since there are some advantages 34 | # to mutable strings, if a string can be reused this saves memory 35 | @buf_str = String.new(encoding: Encoding::BINARY, capacity: @chunk_size * 2) 36 | @delivery_proc = delivery_proc.to_proc 37 | end 38 | 39 | # Appends data to the buffer. Once the size of the chunk has been exceeded, a precisely-sized 40 | # chunk will be passed to the `delivery_proc` 41 | # 42 | # @param bin_str[String] string in binary encoding 43 | # @return self 44 | def <<(bin_str) 45 | @buf_str << bin_str.b 46 | deliver_buf_in_chunks 47 | self 48 | end 49 | 50 | # Appends data to the buffer. Once the size of the chunk has been exceeded, a precisely-sized 51 | # chunk will be passed to the `delivery_proc` 52 | # 53 | # @param bin_str[String] string in binary encoding 54 | # @return [Integer] number of bytes appended to the buffer 55 | def write(bin_str) 56 | self << bin_str 57 | bin_str.bytesize 58 | end 59 | 60 | # Sends the last chunk to the `delivery_proc` even if there is nothing output - 61 | # the last request will usually be needed to close the file 62 | # 63 | # @return void 64 | def finish 65 | deliver_buf_in_chunks 66 | @delivery_proc.call(@buf_str, _is_last_chunk = true) 67 | nil 68 | end 69 | 70 | private def deliver_buf_in_chunks 71 | while @buf_str.bytesize > @chunk_size 72 | @delivery_proc.call(@buf_str[0...@chunk_size], _is_last_chunk = false) 73 | @buf_str.replace(@buf_str[@chunk_size..]) 74 | end 75 | end 76 | end 77 | 78 | # Largely inspired by https://gist.github.com/frankyn/9a5344d1b19ed50ebbf9f15f0ff92032 79 | # Acts like a writable object that you send data into. The object will split the data 80 | # you send into chunks and send it to GCP cloud storage, you do not need to indicate 81 | # the size of the output in advance. You do need to close the object to deliver the 82 | # last chunk 83 | class RangedPutIO 84 | extend Forwardable 85 | def_delegators :@chunker, :write, :finish, :<< 86 | 87 | # The chunks have to be sized in multiples of 256 kibibytes or 262,144 bytes 88 | CHUNK_SIZE_UNIT = 256 * 1024 89 | 90 | def initialize(put_url, chunk_size:, content_type: "binary/octet-stream") 91 | raise ArgumentError, "chunk_size of #{chunk_size} is not a multiple of #{CHUNK_SIZE_UNIT}" unless (chunk_size % CHUNK_SIZE_UNIT).zero? 92 | 93 | @put_uri = URI(put_url) 94 | @last_byte = 0 95 | @total_bytes = 0 96 | @content_type = content_type 97 | @chunker = ByteChunker.new(chunk_size: chunk_size) { |bytes, is_last| upload_chunk(bytes, is_last) } 98 | end 99 | 100 | private 101 | 102 | def upload_chunk(chunk, is_last) 103 | @total_bytes += chunk.bytesize 104 | content_range = if is_last 105 | "bytes #{@last_byte}-#{@last_byte + chunk.bytesize - 1}/#{@total_bytes}" 106 | else 107 | "bytes #{@last_byte}-#{@last_byte + chunk.bytesize - 1}/*" 108 | end 109 | @last_byte += chunk.bytesize 110 | 111 | headers = { 112 | "Content-Length" => chunk.bytesize.to_s, 113 | "Content-Range" => content_range, 114 | "Content-Type" => @content_type, 115 | "Content-MD5" => Digest::MD5.base64digest(chunk) # This is to early flag bugs like the one mentioned below with httpx 116 | } 117 | 118 | # Use plain old Net::HTTP here since currently version 1.4.0 of HTTPX (which is used by Faraday in our env) mangles up the file bytes before upload. 119 | # when passing a File object directly. 120 | # See https://cheddar-me.slack.com/archives/C01FEPX7PA9/p1739290056637849 121 | # and https://gitlab.com/os85/httpx/-/issues/338 122 | put_response = Net::HTTP.put(@put_uri, chunk, headers) 123 | 124 | # This is weird (from https://cloud.google.com/storage/docs/performing-resumable-uploads#resume-upload): 125 | # Repeat the above steps for each remaining chunk of data that you want to upload, using the upper 126 | # value contained in the Range header of each response to determine where to start each successive 127 | # chunk; you should not assume that the server received all bytes sent in any given request. 128 | # So in theory we must check that the "Range:" header in the response is "bytes=0-{@last_byte + chunk.bytesize - 1}" 129 | # and we will add that soon. 130 | # 131 | # 308 means "intermediate chunk uploaded", 200 means "last chunk uploaded" 132 | return if [308, 200].include?(put_response.code.to_i) 133 | 134 | raise "The PUT for the resumable upload responded with status #{put_response.code}, headers #{put_response.to_hash.inspect}" 135 | end 136 | end 137 | 138 | # @param [Google::Cloud::Storage::File] 139 | def initialize(file, content_type: "binary/octet-stream", **signed_url_options) 140 | @file = file 141 | @content_type = content_type 142 | @signed_url_options = url_issuer_and_signer.merge(signed_url_options) 143 | end 144 | 145 | # @yields writable[IO] an IO-ish object that responds to `#write` 146 | def stream(&blk) 147 | session_start_url = @file.signed_url(method: "POST", content_type: @content_type, headers: {"x-goog-resumable": "start"}, **@signed_url_options) 148 | response = Net::HTTP.post(URI(session_start_url), "", {"content-type" => @content_type, "x-goog-resumable" => "start"}) 149 | raise "Expected HTTP status code to be 201, got #{response.code}" unless response.code.to_i == 201 150 | 151 | resumable_upload_session_put_url = response["location"] 152 | writable = RangedPutIO.new(resumable_upload_session_put_url, content_type: @content_type, chunk_size: CHUNK_SIZE_FOR_UPLOADS) 153 | yield(writable) 154 | writable.finish 155 | end 156 | 157 | private 158 | 159 | # This is gnarly. It is needed to allow service accounts (workload identity) to sign 160 | # blobs - which is needed to sign a presigned POST URL. The presigned POST URL allows us 161 | # to initiate a resumable upload. 162 | # 163 | # Comes from here: 164 | # https://github.com/googleapis/google-cloud-ruby/issues/13307#issuecomment-1894546343 165 | def url_issuer_and_signer 166 | env = Google::Cloud.env 167 | if env.compute_engine? 168 | # Issuer is the service account email that the Signed URL will be signed with 169 | # and any permission granted in the Signed URL must be granted to the 170 | # Google Service Account. 171 | issuer = env.lookup_metadata "instance", "service-accounts/default/email" 172 | 173 | # Create a lambda that accepts the string_to_sign 174 | signer = lambda do |string_to_sign| 175 | iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new 176 | 177 | # Get the environment configured authorization 178 | scopes = ["https://www.googleapis.com/auth/iam"] 179 | iam_client.authorization = Google::Auth.get_application_default scopes 180 | 181 | request = Google::Apis::IamcredentialsV1::SignBlobRequest.new( 182 | payload: string_to_sign 183 | ) 184 | resource = "projects/-/serviceAccounts/#{issuer}" 185 | response = iam_client.sign_service_account_blob(resource, request) 186 | response.signed_blob 187 | end 188 | 189 | {issuer:, signer:} 190 | else 191 | {} 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/active_storage_encryption/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveStorageEncryption 4 | VERSION = "0.3.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/add_encryption_key_to_active_storage_blobs.rb.erb: -------------------------------------------------------------------------------- 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 10 | -------------------------------------------------------------------------------- /lib/generators/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/active_record" 5 | 6 | module ActiveStorageEncryption 7 | # The generator is used to install ActiveStorageEncryption. It adds the `encryption_key` 8 | # column to ActiveStorage::Blob. 9 | # Run it with `bin/rails g active_storage_encryption:install` in your console. 10 | class InstallGenerator < Rails::Generators::Base 11 | include ActiveRecord::Generators::Migration 12 | 13 | source_paths << File.join(File.dirname(__FILE__, 2)) 14 | 15 | # Generates monolithic migration file that contains all database changes. 16 | def create_migration_file 17 | # Adding a new migration to the gem is then just adding a file. 18 | migration_file_paths_in_order = Dir.glob(__dir__ + "/*.rb.erb").sort 19 | migration_file_paths_in_order.each do |migration_template_path| 20 | untemplated_migration_filename = File.basename(migration_template_path).gsub(/\.erb$/, "") 21 | migration_template(migration_template_path, File.join(db_migrate_path, untemplated_migration_filename)) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/tasks/active_storage_encryption_tasks.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # desc "Explaining what the task does" 4 | # task :active_storage_encryption do 5 | # # Task goes here 6 | # end 7 | -------------------------------------------------------------------------------- /test/active_storage_encryption_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryptionTest < ActiveSupport::TestCase 6 | test "it has a version number" do 7 | assert ActiveStorageEncryption::VERSION 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative "config/application" 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* Application styles */ 2 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 5 | allow_browser versions: :modern 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationHelper 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | primary_abstract_class 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | has_one_attached :file, service: :encrypted_disk 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Dummy" %> 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= csp_meta_tag %> 9 | 10 | <%= yield :head %> 11 | 12 | 13 | 14 | 15 | 16 | <%= stylesheet_link_tag "application" %> 17 | 18 | 19 | 20 | <%= yield %> 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dummy", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Dummy.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /test/dummy/app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | APP_NAME = "dummy" 6 | 7 | def system!(*args) 8 | system(*args, exception: true) 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | 34 | # puts "\n== Configuring puma-dev ==" 35 | # system "ln -nfs #{APP_ROOT} ~/.puma-dev/#{APP_NAME}" 36 | # system "curl -Is https://#{APP_NAME}.test/up | head -n 1" 37 | end 38 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | Rails.application.load_server 9 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "boot" 4 | 5 | require "rails" 6 | # Pick the frameworks you want: 7 | require "active_model/railtie" 8 | # require "active_job/railtie" 9 | require "active_record/railtie" 10 | require "active_storage/engine" 11 | require "action_controller/railtie" 12 | # require "action_mailer/railtie" 13 | # require "action_mailbox/engine" 14 | # require "action_text/engine" 15 | # require "action_view/railtie" 16 | # require "action_cable/engine" 17 | require "rails/test_unit/railtie" 18 | 19 | # Require the gems listed in Gemfile, including any gems 20 | # you've limited to :test, :development, or :production. 21 | Bundler.require(*Rails.groups) 22 | 23 | module Dummy 24 | class Application < Rails::Application 25 | config.load_defaults Rails::VERSION::STRING.to_f 26 | 27 | # For compatibility with applications that use this config 28 | config.action_controller.include_all_helpers = false 29 | 30 | # Please, add to the `ignore` list any other `lib` subdirectories that do 31 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 32 | # Common ones are `templates`, `generators`, or `middleware`, for example. 33 | config.autoload_lib(ignore: %w[assets tasks]) 34 | 35 | # Configuration for the application, engines, and railties goes here. 36 | # 37 | # These settings can be overridden in specific environments using the files 38 | # in config/environments, which are processed later. 39 | # 40 | # config.time_zone = "Central Time (US & Canada)" 41 | # config.eager_load_paths << Rails.root.join("extras") 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Set up gems listed in the Gemfile. 4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 5 | 6 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 7 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 8 | -------------------------------------------------------------------------------- /test/dummy/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | 2l6uOM8Eocu/ZzELRAuQAW063JqYqj5SEFAkkDNOHIjF4GVv7oArN63UEga8QUMs1ac6Qvj/Smlpx6S+Kr32Zww+fCVHdZ95mSA41+W5C/uX1hVaWpTL3ndcIJL3VgkQ90gpPKr8eIeeTW+CEcXrxSlAfypDp1ULJQ1F+/qXYUOyXGln7Zzcg/GQHGYQyA2hQHAHAb3YtQ47yc3eBwrBV5M4/PL9fOAWZ0t3sGWCjShaPDMYoKTy75tuAZrV7EzxWzGJbRBGL0710oWA3VvmoEZE6MScOJqII2Rla+iwcFmXmkXbHhXZvJ5465Wk+4mWwBx9vm9OL0BtBxKrAKK0z73pP0RStlSge11pojcoS38evvEIL96r1IL32RfKReRA6uaT5mECUrWNpGsORTayXAfjeR6hwGhh1UXAu2yIEPn2VFTBWnS2knP5KoCf/oyBQ2RY9IqIyOIjRRUzlqThS0Xj9LvqXQSS5giFJRxLZs4YL3zYhsQl31aaIMtG4bWF3mVDSp/Bv6Y7rVqVFRZWB+vN9JGxtlGWcwfCAFazgzSlN7SYHGg0KecCLARlcAsaaX/Ll15YTDmexOZqoSNCMQac2sDUmcD2WQs2UrNVjoSpSsGjRUtV1RmpUu0/DPCioBbmP5ekB1QziI608w==--fldX094wtj6HqTmD--YacYv9MBtmejAYn9+oIBYg== -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative "application" 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/integer/time" 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in config/application.rb. 7 | 8 | # In the development environment your application's code is reloaded any time 9 | # it changes. This slows down response time but is perfect for development 10 | # since you don't have to restart the web server when you make code changes. 11 | config.enable_reloading = true 12 | 13 | # Do not eager load code on boot. 14 | config.eager_load = false 15 | 16 | # Show full error reports. 17 | config.consider_all_requests_local = true 18 | 19 | # Enable server timing. 20 | config.server_timing = true 21 | 22 | # Enable/disable caching. By default caching is disabled. 23 | # Run rails dev:cache to toggle caching. 24 | if Rails.root.join("tmp/caching-dev.txt").exist? 25 | config.action_controller.perform_caching = true 26 | config.action_controller.enable_fragment_cache_logging = true 27 | 28 | config.cache_store = :memory_store 29 | config.public_file_server.headers = {"Cache-Control" => "public, max-age=#{2.days.to_i}"} 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Print deprecation notices to the Rails logger. 37 | config.active_support.deprecation = :log 38 | 39 | # Raise exceptions for disallowed deprecations. 40 | config.active_support.disallowed_deprecation = :raise 41 | 42 | # Tell Active Support which deprecation messages to disallow. 43 | config.active_support.disallowed_deprecation_warnings = [] 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Raises error for missing translations. 52 | # config.i18n.raise_on_missing_translations = true 53 | 54 | # Annotate rendered view with file names. 55 | config.action_view.annotate_rendered_view_with_filenames = true 56 | 57 | # Raise error when a before_action's only/except options reference missing actions. 58 | config.action_controller.raise_on_missing_callback_actions = true 59 | end 60 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/integer/time" 4 | 5 | Rails.application.configure do 6 | # Settings specified here will take precedence over those in config/application.rb. 7 | 8 | # Code is not reloaded between requests. 9 | config.enable_reloading = false 10 | 11 | # Eager load code on boot. This eager loads most of Rails and 12 | # your application in memory, allowing both threaded web servers 13 | # and those relying on copy on write to perform better. 14 | # Rake tasks automatically ignore this option for performance. 15 | config.eager_load = true 16 | 17 | # Full error reports are disabled and caching is turned on. 18 | config.consider_all_requests_local = false 19 | config.action_controller.perform_caching = true 20 | 21 | # Ensures that a master key has been made available in ENV["RAILS_MASTER_KEY"], config/master.key, or an environment 22 | # key such as config/credentials/production.key. This key is used to decrypt credentials (and other encrypted files). 23 | # config.require_master_key = true 24 | 25 | # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. 26 | # config.public_file_server.enabled = false 27 | 28 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 29 | # config.asset_host = "http://assets.example.com" 30 | 31 | # Specifies the header that your server uses for sending files. 32 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 33 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 34 | 35 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 36 | # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. 37 | # config.assume_ssl = true 38 | 39 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 40 | config.force_ssl = true 41 | 42 | # Skip http-to-https redirect for the default health check endpoint. 43 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 44 | 45 | # Log to STDOUT by default 46 | config.logger = ActiveSupport::Logger.new($stdout) 47 | .tap { |logger| logger.formatter = ::Logger::Formatter.new } 48 | .then { |logger| ActiveSupport::TaggedLogging.new(logger) } 49 | 50 | # Prepend all log lines with the following tags. 51 | config.log_tags = [:request_id] 52 | 53 | # "info" includes generic and useful information about system operation, but avoids logging too much 54 | # information to avoid inadvertent exposure of personally identifiable information (PII). If you 55 | # want to log everything, set the level to "debug". 56 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 62 | # the I18n.default_locale when a translation cannot be found). 63 | config.i18n.fallbacks = true 64 | 65 | # Don't log any deprecations. 66 | config.active_support.report_deprecations = false 67 | 68 | # Do not dump schema after migrations. 69 | config.active_record.dump_schema_after_migration = false 70 | 71 | # Only use :id for inspections in production. 72 | config.active_record.attributes_for_inspect = [:id] 73 | 74 | # Enable DNS rebinding protection and other `Host` header attacks. 75 | # config.hosts = [ 76 | # "example.com", # Allow requests from example.com 77 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 78 | # ] 79 | # Skip DNS rebinding protection for the default health check endpoint. 80 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 81 | end 82 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/integer/time" 4 | 5 | # The test environment is used exclusively to run your application's 6 | # test suite. You never need to work with it otherwise. Remember that 7 | # your test database is "scratch space" for the test suite and is wiped 8 | # and recreated between test runs. Don't rely on the data there! 9 | 10 | Rails.application.configure do 11 | # Settings specified here will take precedence over those in config/application.rb. 12 | 13 | # While tests run files are not watched, reloading is not necessary. 14 | config.enable_reloading = false 15 | 16 | # Eager loading loads your entire application. When running a single test locally, 17 | # this is usually not necessary, and can slow down your test suite. However, it's 18 | # recommended that you enable it in continuous integration systems to ensure eager 19 | # loading is working properly before deploying your code. 20 | config.eager_load = ENV["CI"].present? 21 | 22 | # Configure public file server for tests with Cache-Control for performance. 23 | config.public_file_server.headers = {"Cache-Control" => "public, max-age=#{1.hour.to_i}"} 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Render exception templates for rescuable exceptions and raise for other exceptions. 31 | config.action_dispatch.show_exceptions = :rescuable 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Print deprecation notices to the stderr. 37 | config.active_support.deprecation = :stderr 38 | 39 | # Raise exceptions for disallowed deprecations. 40 | config.active_support.disallowed_deprecation = :raise 41 | 42 | # Tell Active Support which deprecation messages to disallow. 43 | config.active_support.disallowed_deprecation_warnings = [] 44 | 45 | config.active_storage.service = :test 46 | 47 | # Raises error for missing translations. 48 | # config.i18n.raise_on_missing_translations = true 49 | 50 | # Annotate rendered view with file names. 51 | # config.action_view.annotate_rendered_view_with_filenames = true 52 | 53 | # Raise error when a before_action's only/except options reference missing actions. 54 | config.action_controller.raise_on_missing_callback_actions = true 55 | end 56 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide content security policy. 6 | # See the Securing Rails Applications Guide for more information: 7 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 8 | 9 | # Rails.application.configure do 10 | # config.content_security_policy do |policy| 11 | # policy.default_src :self, :https 12 | # policy.font_src :self, :https, :data 13 | # policy.img_src :self, :https, :data 14 | # policy.object_src :none 15 | # policy.script_src :self, :https 16 | # policy.style_src :self, :https 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | # 21 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 22 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 23 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 24 | # 25 | # # Report violations without enforcing the policy. 26 | # # config.content_security_policy_report_only = true 27 | # end 28 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 6 | # Use this to limit dissemination of sensitive information. 7 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 8 | Rails.application.config.filter_parameters += [ 9 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 10 | ] 11 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Add new inflection rules using the following format. Inflections 6 | # are locale specific, and you may define rules for as many different 7 | # locales as you wish. All of these examples are active by default: 8 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 9 | # inflect.plural /^(ox)$/i, "\\1en" 10 | # inflect.singular /^(ox)en/i, "\\1" 11 | # inflect.irregular "person", "people" 12 | # inflect.uncountable %w( fish sheep ) 13 | # end 14 | 15 | # These inflection rules are supported but not enabled by default: 16 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 17 | # inflect.acronym "RESTful" 18 | # end 19 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | # Define an application-wide HTTP permissions policy. For further 6 | # information see: https://developers.google.com/web/updates/2018/06/feature-policy 7 | 8 | # Rails.application.config.permissions_policy do |policy| 9 | # policy.camera :none 10 | # policy.gyroscope :none 11 | # policy.microphone :none 12 | # policy.usb :none 13 | # policy.fullscreen :self 14 | # policy.payment :self, "https://secure.example.com" 15 | # end 16 | -------------------------------------------------------------------------------- /test/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /test/dummy/config/master.key: -------------------------------------------------------------------------------- 1 | 4b99c805920481b14131c91433d0d985 -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This configuration file will be evaluated by Puma. The top-level methods that 4 | # are invoked here are part of Puma's configuration DSL. For more information 5 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 6 | 7 | # Puma starts a configurable number of processes (workers) and each process 8 | # serves each request in a thread from an internal thread pool. 9 | # 10 | # The ideal number of threads per worker depends both on how much time the 11 | # application spends waiting for IO operations and on how much you wish to 12 | # to prioritize throughput over latency. 13 | # 14 | # As a rule of thumb, increasing the number of threads will increase how much 15 | # traffic a given process can handle (throughput), but due to CRuby's 16 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 17 | # response time (latency) of the application. 18 | # 19 | # The default is set to 3 threads as it's deemed a decent compromise between 20 | # throughput and latency for the average Rails application. 21 | # 22 | # Any libraries that use a connection pool or another resource pool should 23 | # be configured to provide at least as many connections as the number of 24 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 25 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 26 | threads threads_count, threads_count 27 | 28 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 29 | port ENV.fetch("PORT", 3000) 30 | 31 | # Allow puma to be restarted by `bin/rails restart` command. 32 | plugin :tmp_restart 33 | 34 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 35 | # In other environments, only set the PID file if requested. 36 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 37 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | mount ActiveStorageEncryption::Engine => "/active-storage-encryption" 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | development: 2 | public: true 3 | service: Disk 4 | root: <%= Rails.root.join("storage") %> 5 | 6 | test: 7 | public: true 8 | service: Disk 9 | root: <%= Rails.root.join("storage") %> 10 | 11 | encrypted_disk: 12 | service: EncryptedDisk 13 | root: <%= Rails.root.join("storage") %> 14 | private_url_policy: stream 15 | 16 | encrypted_mirror: 17 | service: EncryptedMirror 18 | primary: 19 | - encrypted_disk 20 | mirrors: 21 | - test 22 | 23 | encrypted_gcs_service: 24 | service: EncryptedGCS 25 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250304023851_create_active_storage_tables.active_storage.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This migration comes from active_storage (originally 20170806125915) 4 | class CreateActiveStorageTables < ActiveRecord::Migration[7.0] 5 | def change 6 | # Use Active Record's configured type for primary and foreign keys 7 | primary_key_type, foreign_key_type = primary_and_foreign_key_types 8 | 9 | create_table :active_storage_blobs, id: primary_key_type do |t| 10 | t.string :key, null: false 11 | t.string :filename, null: false 12 | t.string :content_type 13 | t.text :metadata 14 | t.string :service_name, null: false 15 | t.bigint :byte_size, null: false 16 | t.string :checksum 17 | 18 | if connection.supports_datetime_with_precision? 19 | t.datetime :created_at, precision: 6, null: false 20 | else 21 | t.datetime :created_at, null: false 22 | end 23 | 24 | t.index [:key], unique: true 25 | end 26 | 27 | create_table :active_storage_attachments, id: primary_key_type do |t| 28 | t.string :name, null: false 29 | t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type 30 | t.references :blob, null: false, type: foreign_key_type 31 | 32 | if connection.supports_datetime_with_precision? 33 | t.datetime :created_at, precision: 6, null: false 34 | else 35 | t.datetime :created_at, null: false 36 | end 37 | 38 | t.index [:record_type, :record_id, :name, :blob_id], name: :index_active_storage_attachments_uniqueness, unique: true 39 | t.foreign_key :active_storage_blobs, column: :blob_id 40 | end 41 | 42 | create_table :active_storage_variant_records, id: primary_key_type do |t| 43 | t.belongs_to :blob, null: false, index: false, type: foreign_key_type 44 | t.string :variation_digest, null: false 45 | 46 | t.index [:blob_id, :variation_digest], name: :index_active_storage_variant_records_uniqueness, unique: true 47 | t.foreign_key :active_storage_blobs, column: :blob_id 48 | end 49 | end 50 | 51 | private 52 | 53 | def primary_and_foreign_key_types 54 | config = Rails.configuration.generators 55 | setting = config.options[config.orm][:primary_key_type] 56 | primary_key_type = setting || :primary_key 57 | foreign_key_type = setting || :bigint 58 | [primary_key_type, foreign_key_type] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250304023853_add_blob_encryption_key_column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddBlobEncryptionKeyColumn < ActiveRecord::Migration[7.2] 4 | def change 5 | add_column :active_storage_blobs, :encryption_key, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250428093315_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.2] 2 | def change 3 | create_table :users do |t| 4 | t.timestamps 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.2].define(version: 2025_04_28_093315) do 14 | create_table "active_storage_attachments", force: :cascade do |t| 15 | t.string "name", null: false 16 | t.string "record_type", null: false 17 | t.bigint "record_id", null: false 18 | t.bigint "blob_id", null: false 19 | t.datetime "created_at", null: false 20 | t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" 21 | t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true 22 | end 23 | 24 | create_table "active_storage_blobs", force: :cascade do |t| 25 | t.string "key", null: false 26 | t.string "filename", null: false 27 | t.string "content_type" 28 | t.text "metadata" 29 | t.string "service_name", null: false 30 | t.bigint "byte_size", null: false 31 | t.string "checksum" 32 | t.datetime "created_at", null: false 33 | t.string "encryption_key" 34 | t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true 35 | end 36 | 37 | create_table "active_storage_variant_records", force: :cascade do |t| 38 | t.bigint "blob_id", null: false 39 | t.string "variation_digest", null: false 40 | t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true 41 | end 42 | 43 | create_table "users", force: :cascade do |t| 44 | t.datetime "created_at", null: false 45 | t.datetime "updated_at", null: false 46 | end 47 | 48 | add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" 49 | add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" 50 | end 51 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/log/.keep -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Your browser is not supported (406) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

Your browser is not supported.

62 |

Please upgrade your browser to continue.

63 |
64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/dummy/public/icon.png -------------------------------------------------------------------------------- /test/dummy/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /test/integration/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cheddar-me/active_storage_encryption/641a40530620d0a1162f53d75bca111aba4061f4/test/integration/.keep -------------------------------------------------------------------------------- /test/integration/encrypted_blob_proxy_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryptionEncryptedBlobProxyControllerTest < ActionDispatch::IntegrationTest 6 | setup do 7 | @storage_dir = Dir.mktmpdir 8 | @other_storage_dir = Dir.mktmpdir 9 | @service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir, private_url_policy: "stream") 10 | @service.name = "amazing_encrypting_disk_service" # Needed for the controller and service lookup 11 | 12 | # Hack: sneakily add our service to them configurations 13 | # ActiveStorage::Blob.services.send(:services)["amazing_encrypting_disk_service"] = @service 14 | 15 | # We need to set our service as the default, because the controller does lookup from the application config - 16 | # which does not include the service we define here 17 | @previous_default_service = ActiveStorage::Blob.service 18 | @previous_services = ActiveStorage::Blob.services 19 | 20 | # To catch potential issues where something goes to the default service by mistake, let's set a 21 | # different Service as the default 22 | @non_encrypted_default_service = ActiveStorage::Service::DiskService.new(root: @other_storage_dir) 23 | ActiveStorage::Blob.service = @non_encrypted_default_service 24 | ActiveStorage::Blob.services = {@service.name => @service} # That too 25 | 26 | # This needs to be set 27 | ActiveStorageEncryption::Engine.routes.default_url_options = {host: "www.example.com"} 28 | 29 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 30 | # see https://stackoverflow.com/a/60573259/153886 31 | ActiveStorage::Current.url_options = { 32 | host: "www.example.com", 33 | protocol: "https" 34 | } 35 | freeze_time # For testing expiring tokens 36 | https! # So that all requests are simulated as SSL 37 | end 38 | 39 | def teardown 40 | unfreeze_time 41 | ActiveStorage::Blob.service = @previous_default_service 42 | ActiveStorage::Blob.services = @previous_services 43 | FileUtils.rm_rf(@storage_dir) 44 | FileUtils.rm_rf(@other_storage_dir) 45 | end 46 | 47 | def engine_routes 48 | ActiveStorageEncryption::Engine.routes.url_helpers 49 | end 50 | 51 | test "show() serves the complete decrypted blob body" do 52 | rng = Random.new(Minitest.seed) 53 | plaintext = rng.bytes(512) 54 | 55 | blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(plaintext), content_type: "x-office/severance", filename: "secret.bin", service_name: @service.name) 56 | assert blob.encryption_key 57 | 58 | streaming_url = blob.url(disposition: "inline") # This generates a URL with the byte size 59 | get streaming_url 60 | 61 | assert_response :success 62 | assert_equal "x-office/severance", response.headers["content-type"] 63 | assert_equal blob.key.inspect, response.headers["etag"] 64 | assert_equal plaintext, response.body 65 | end 66 | 67 | test "show() serves a blob of 0 size" do 68 | Random.new(Minitest.seed) 69 | plaintext = "".b 70 | 71 | blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(plaintext), content_type: "x-office/severance", filename: "secret.bin", service_name: @service.name) 72 | assert blob.encryption_key 73 | 74 | streaming_url = blob.url(disposition: "inline") # This generates a URL with the byte size 75 | get streaming_url 76 | 77 | assert_response :success 78 | assert response.body.empty? 79 | end 80 | 81 | test "show() returns a 404 when the blob no longer exists on the service" do 82 | Random.new(Minitest.seed) 83 | plaintext = "hello" 84 | 85 | blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(plaintext), content_type: "x-office/severance", filename: "secret.bin", service_name: @service.name) 86 | assert blob.encryption_key 87 | 88 | streaming_url = blob.url(disposition: "inline") # This generates a URL with the byte size 89 | blob.service.delete(blob.key) 90 | 91 | get streaming_url 92 | 93 | assert_response :not_found 94 | end 95 | 96 | test "show() serves HTTP ranges" do 97 | rng = Random.new(Minitest.seed) 98 | plaintext = rng.bytes(5.megabytes + 13) 99 | 100 | blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(plaintext), content_type: "x-office/severance", filename: "secret.bin", service_name: @service.name) 101 | assert blob.encryption_key 102 | 103 | streaming_url = blob.url(disposition: "inline") # This generates a URL with the byte size 104 | get streaming_url, headers: {"Range" => "bytes=0-0"} 105 | 106 | assert_response :partial_content 107 | assert_equal "1", response.headers["content-length"] 108 | assert_equal "bytes 0-0/5242893", response.headers["content-range"] 109 | assert_equal "x-office/severance", response.headers["content-type"] 110 | assert_equal plaintext[0..0], response.body 111 | 112 | get streaming_url, headers: {"Range" => "bytes=1-2"} 113 | 114 | assert_response :partial_content 115 | assert_equal "2", response.headers["content-length"] 116 | assert_equal "bytes 1-2/5242893", response.headers["content-range"] 117 | assert_equal "x-office/severance", response.headers["content-type"] 118 | assert_equal plaintext[1..2], response.body 119 | 120 | get streaming_url, headers: {"Range" => "bytes=1-2,8-10,12-23"} 121 | 122 | assert_response :partial_content 123 | assert response.headers["content-type"].start_with?("multipart/byteranges; boundary=") 124 | assert_nil response.headers["content-range"] 125 | assert_equal 350, response.body.bytesize 126 | 127 | get streaming_url, headers: {"Range" => "bytes=99999999999999999-99999999999999999"} 128 | assert_response :range_not_satisfiable 129 | end 130 | 131 | test "show() refuses a request which goes to a non-encrypted Service" do 132 | rng = Random.new(Minitest.seed) 133 | 134 | key = SecureRandom.base36(12) 135 | encryption_key = rng.bytes(32) 136 | plaintext = rng.bytes(512) 137 | @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key) 138 | 139 | streaming_url = @service.url(key, encryption_key: encryption_key, filename: ActiveStorage::Filename.new("private.doc"), 140 | expires_in: 30.seconds, disposition: "inline", content_type: "x-office/severance", 141 | blob_byte_size: plaintext.bytesize) 142 | 143 | # Sneak in a non-encrypted service under the same key 144 | ActiveStorage::Blob.services[@service.name] = @non_encrypted_default_service 145 | 146 | get streaming_url 147 | assert_response :forbidden 148 | end 149 | 150 | test "show() refuses a request which has an incorrect encryption key" do 151 | rng = Random.new(Minitest.seed) 152 | 153 | key = SecureRandom.base36(12) 154 | encryption_key = rng.bytes(32) 155 | plaintext = rng.bytes(512) 156 | @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key) 157 | 158 | another_encryption_key = rng.bytes(32) 159 | refute_equal encryption_key, another_encryption_key 160 | 161 | streaming_url = @service.url(key, encryption_key: another_encryption_key, 162 | filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, 163 | disposition: "inline", content_type: "x-office/severance", blob_byte_size: plaintext.bytesize) 164 | get streaming_url 165 | 166 | assert_response :forbidden 167 | end 168 | 169 | test "show() refuses a request with a garbage token" do 170 | get engine_routes.encrypted_blob_streaming_get_path(token: "garbage", filename: "exfil.bin") 171 | assert_response :forbidden 172 | end 173 | 174 | test "show() refuses a request with a token that has been encrypted using an incorrect encryption key" do 175 | https! 176 | rng = Random.new(Minitest.seed) 177 | encryptor_key = rng.bytes(32) 178 | other_encryptor = ActiveStorageEncryption::TokenEncryptor.new(encryptor_key, url_safe: encryptor_key) 179 | 180 | key = SecureRandom.base36(12) 181 | encryption_key = rng.bytes(32) 182 | @service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key) 183 | 184 | streaming_url = ActiveStorageEncryption.stub(:token_encryptor, -> { other_encryptor }) do 185 | @service.url(key, encryption_key: encryption_key, 186 | filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, 187 | disposition: "inline", content_type: "binary/octet-stream", 188 | blob_byte_size: 512) 189 | end 190 | 191 | get streaming_url 192 | assert_response :forbidden 193 | end 194 | 195 | test "show() refuses a request with a token that has expired" do 196 | rng = Random.new(Minitest.seed) 197 | 198 | key = SecureRandom.base36(12) 199 | encryption_key = rng.bytes(32) 200 | @service.upload(key, StringIO.new(rng.bytes(512)).binmode, encryption_key: encryption_key) 201 | 202 | streaming_url = @service.url(key, encryption_key: encryption_key, 203 | filename: ActiveStorage::Filename.new("private.doc"), expires_in: 3.seconds, 204 | disposition: "inline", content_type: "binary/octet-stream", 205 | blob_byte_size: 512) 206 | travel 5.seconds 207 | 208 | get streaming_url 209 | assert_response :forbidden 210 | end 211 | 212 | test "show() requires headers if the private_url_policy of the service is set to :require_headers" do 213 | rng = Random.new(Minitest.seed) 214 | 215 | key = SecureRandom.base36(12) 216 | encryption_key = rng.bytes(32) 217 | plaintext = rng.bytes(512) 218 | @service.upload(key, StringIO.new(plaintext).binmode, encryption_key: encryption_key) 219 | 220 | # The policy needs to be set before we generate the token (the token includes require_headers) 221 | @service.private_url_policy = :require_headers 222 | streaming_url = @service.url(key, encryption_key: encryption_key, 223 | filename: ActiveStorage::Filename.new("private.doc"), expires_in: 30.seconds, disposition: "inline", 224 | content_type: "x-office/severance", blob_byte_size: plaintext.bytesize) 225 | 226 | get streaming_url 227 | assert_response :forbidden # Without headers 228 | 229 | get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(encryption_key)} 230 | assert_response :success 231 | assert_equal "x-office/severance", response.headers["content-type"] 232 | assert_equal plaintext, response.body 233 | end 234 | 235 | test "show() refuses a request if the service no longer permits private URLs, even if the URL was generated when it used to permit them" do 236 | rng = Random.new(Minitest.seed) 237 | 238 | SecureRandom.base36(12) 239 | plaintext = rng.bytes(512) 240 | 241 | blob = ActiveStorage::Blob.create_and_upload!(io: StringIO.new(plaintext), content_type: "x-office/severance", filename: "secret.bin", service_name: @service.name) 242 | assert blob.encryption_key 243 | streaming_url = blob.url(disposition: "inline", content_type: "x-office/severance") 244 | 245 | @service.private_url_policy = :disable 246 | 247 | get streaming_url 248 | assert_response :forbidden # Without headers 249 | 250 | get streaming_url, headers: {"HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(blob.encryption_key)} 251 | assert_response :forbidden # With headers 252 | end 253 | end 254 | -------------------------------------------------------------------------------- /test/integration/encrypted_blobs_controller_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryptionEncryptedBlobsControllerTest < ActionDispatch::IntegrationTest 6 | setup do 7 | @storage_dir = Dir.mktmpdir 8 | @other_storage_dir = Dir.mktmpdir 9 | @service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir, private_url_policy: "stream") 10 | @service.name = "amazing_encrypting_disk_service" # Needed for the controller and service lookup 11 | 12 | # We need to set our service as the default, because the controller does lookup from the application config - 13 | # which does not include the service we define here 14 | @previous_default_service = ActiveStorage::Blob.service 15 | @previous_services = ActiveStorage::Blob.services 16 | 17 | # To catch potential issues where something goes to the default service by mistake, let's set a 18 | # different Service as the default 19 | @non_encrypted_default_service = ActiveStorage::Service::DiskService.new(root: @other_storage_dir) 20 | ActiveStorage::Blob.service = @non_encrypted_default_service 21 | ActiveStorage::Blob.services = {@service.name => @service} # That too 22 | 23 | # This needs to be set 24 | ActiveStorageEncryption::Engine.routes.default_url_options = {host: "www.example.com"} 25 | 26 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 27 | # see https://stackoverflow.com/a/60573259/153886 28 | ActiveStorage::Current.url_options = { 29 | host: "www.example.com", 30 | protocol: "https" 31 | } 32 | freeze_time # For testing expiring tokens 33 | https! # So that all requests are simulated as SSL 34 | end 35 | 36 | def teardown 37 | unfreeze_time 38 | ActiveStorage::Blob.service = @previous_default_service 39 | ActiveStorage::Blob.services = @previous_services 40 | FileUtils.rm_rf(@storage_dir) 41 | FileUtils.rm_rf(@other_storage_dir) 42 | end 43 | 44 | def engine_routes 45 | ActiveStorageEncryption::Engine.routes.url_helpers 46 | end 47 | 48 | test "create_direct_upload creates a blob and returns the headers and the URL to start the upload, which are for the correct service name" do 49 | rng = Random.new(Minitest.seed) 50 | plaintext = rng.bytes(512) 51 | 52 | params = { 53 | service_name: @service.name, 54 | blob: { 55 | content_type: "x-binary/sensitive", 56 | filename: "biometrics.sec", 57 | checksum: Digest::MD5.base64digest(plaintext), 58 | service_name: @service.name, 59 | byte_size: plaintext.bytesize, 60 | metadata: {womp: 1} 61 | } 62 | } 63 | 64 | post engine_routes.create_encrypted_blob_direct_upload_url, params: params 65 | 66 | assert_response :success 67 | 68 | body_payload = JSON.parse(response.body, symbolize_names: true) 69 | 70 | assert_equal "amazing_encrypting_disk_service", body_payload[:service_name] 71 | assert_equal "biometrics.sec", body_payload[:filename] 72 | assert_equal({womp: "1"}, body_payload[:metadata]) 73 | assert_equal Digest::MD5.base64digest(plaintext), body_payload[:checksum] 74 | assert_kind_of String, body_payload[:direct_upload][:url] 75 | assert_kind_of Hash, body_payload[:direct_upload][:headers] 76 | assert_kind_of String, body_payload[:direct_upload][:headers][:"x-active-storage-encryption-key"] 77 | 78 | blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id]) 79 | assert blob.encryption_key 80 | assert_equal blob.service, @service 81 | end 82 | 83 | test "create_direct_upload creates a blob which can then be uploaded via PUT" do 84 | rng = Random.new(Minitest.seed) 85 | plaintext = rng.bytes(512) 86 | 87 | params = { 88 | service_name: @service.name, 89 | blob: { 90 | content_type: "x-binary/sensitive", 91 | filename: "biometrics.sec", 92 | checksum: Digest::MD5.base64digest(plaintext), 93 | service_name: @service.name, 94 | byte_size: plaintext.bytesize 95 | } 96 | } 97 | 98 | post engine_routes.create_encrypted_blob_direct_upload_url, params: params 99 | assert_response :success 100 | 101 | body_payload = JSON.parse(response.body, symbolize_names: true) 102 | url_to_put_to = body_payload[:direct_upload][:url] 103 | headers = body_payload[:direct_upload][:headers] 104 | put url_to_put_to, headers: headers, params: plaintext 105 | assert_response :no_content 106 | 107 | blob = ActiveStorage::Blob.find_signed!(body_payload[:signed_id]) 108 | readback = blob.download 109 | assert_equal plaintext, readback 110 | end 111 | 112 | test "create_direct_upload refuses without being given an MD5 checksum" do 113 | rng = Random.new(Minitest.seed) 114 | plaintext = rng.bytes(512) 115 | 116 | params = { 117 | service_name: @service.name, 118 | blob: { 119 | content_type: "x-binary/sensitive", 120 | filename: "biometrics.sec", 121 | service_name: @service.name, 122 | byte_size: plaintext.bytesize 123 | } 124 | } 125 | 126 | post engine_routes.create_encrypted_blob_direct_upload_url, params: params 127 | assert_response :unprocessable_entity 128 | end 129 | 130 | test "update() uploads the blob binary data to an encrypted Service using HTTP PUT" do 131 | rng = Random.new(Minitest.seed) 132 | plaintext = rng.bytes(512) 133 | b64_md5 = Digest::MD5.base64digest(plaintext) 134 | 135 | key = rng.hex(12) 136 | encryption_key = rng.bytes(65) 137 | 138 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 139 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 140 | 141 | put destination_url, headers: headers, params: plaintext 142 | assert_response :no_content 143 | 144 | assert @service.exist?(key) 145 | readback = @service.download(key, encryption_key: encryption_key) 146 | assert_equal plaintext, readback 147 | end 148 | 149 | test "update() refuses to upload if no Content-MD5 is sent with the request" do 150 | rng = Random.new(Minitest.seed) 151 | plaintext = rng.bytes(512) 152 | b64_md5 = Digest::MD5.base64digest(plaintext) 153 | 154 | key = rng.hex(12) 155 | encryption_key = rng.bytes(65) 156 | 157 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 158 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 159 | 160 | headers.delete_if { |k, _| k.downcase == "content-md5" } 161 | put destination_url, headers: headers, params: plaintext 162 | assert_response :unprocessable_entity 163 | end 164 | 165 | test "update() refuses to upload if Content-MD5 from headers differs from the one in the token" do 166 | rng = Random.new(Minitest.seed) 167 | plaintext = rng.bytes(512) 168 | b64_md5 = Digest::MD5.base64digest(plaintext) 169 | 170 | key = rng.hex(12) 171 | encryption_key = rng.bytes(65) 172 | 173 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 174 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 175 | 176 | wrong_md5 = Digest::MD5.base64digest(plaintext + "t") 177 | headers["content-md5"] = wrong_md5 178 | 179 | put destination_url, headers: headers, params: plaintext 180 | assert_response :unprocessable_entity 181 | end 182 | 183 | test "update() refuses to upload if no encryption key is present in the header" do 184 | rng = Random.new(Minitest.seed) 185 | plaintext = rng.bytes(512) 186 | b64_md5 = Digest::MD5.base64digest(plaintext) 187 | 188 | key = rng.hex(12) 189 | encryption_key = rng.bytes(65) 190 | 191 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 192 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 193 | 194 | headers.delete_if { |k, _| k.downcase == "x-active-storage-encryption-key" } 195 | put destination_url, headers: headers, params: plaintext 196 | assert_response :unprocessable_entity 197 | end 198 | 199 | test "update() refuses to upload if plaintext is different to the one the checksum has been calculated for" do 200 | rng = Random.new(Minitest.seed) 201 | plaintext = rng.bytes(512) 202 | b64_md5 = Digest::MD5.base64digest(plaintext) 203 | 204 | key = rng.hex(12) 205 | encryption_key = rng.bytes(65) 206 | 207 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 208 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 209 | 210 | different_plaintext = rng.bytes(plaintext.bytesize) 211 | refute_equal different_plaintext, plaintext 212 | 213 | put destination_url, headers: headers, params: different_plaintext 214 | assert_response :unprocessable_entity 215 | 216 | refute @service.exist?(key) 217 | end 218 | 219 | test "update() refuses to upload if plaintext has a different length than stated during token generation" do 220 | rng = Random.new(Minitest.seed) 221 | plaintext = rng.bytes(512) 222 | b64_md5 = Digest::MD5.base64digest(plaintext) 223 | 224 | key = rng.hex(12) 225 | encryption_key = rng.bytes(65) 226 | 227 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 228 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: (plaintext.bytesize - 44), checksum: b64_md5, encryption_key: encryption_key) 229 | 230 | put destination_url, headers: headers, params: plaintext 231 | assert_response :unprocessable_entity 232 | end 233 | 234 | test "update() refuses to upload if the encryption key given in the header is different than the one used to generate the URL" do 235 | rng = Random.new(Minitest.seed) 236 | plaintext = rng.bytes(512) 237 | b64_md5 = Digest::MD5.base64digest(plaintext) 238 | 239 | key = rng.hex(12) 240 | encryption_key = rng.bytes(65) 241 | 242 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 243 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 244 | 245 | other_encryption_key = rng.bytes(65) 246 | refute_equal other_encryption_key, encryption_key 247 | 248 | headers["x-active-storage-encryption-key"] = Base64.strict_encode64(other_encryption_key) 249 | 250 | put destination_url, headers: headers, params: plaintext 251 | assert_response :unprocessable_entity 252 | end 253 | 254 | test "update() refuses to upload if the token in the URL has expired" do 255 | rng = Random.new(Minitest.seed) 256 | plaintext = rng.bytes(512) 257 | b64_md5 = Digest::MD5.base64digest(plaintext) 258 | 259 | key = rng.hex(12) 260 | encryption_key = rng.bytes(65) 261 | 262 | headers = @service.headers_for_direct_upload(key, content_type: "binary/octet-stream", encryption_key: encryption_key, checksum: b64_md5) 263 | destination_url = @service.url_for_direct_upload(key, expires_in: 5.seconds, content_type: "binary/octet-stream", content_length: plaintext.bytesize, checksum: b64_md5, encryption_key: encryption_key) 264 | 265 | travel 10.seconds 266 | 267 | put destination_url, headers: headers, params: plaintext 268 | assert_response :unprocessable_entity 269 | end 270 | end 271 | -------------------------------------------------------------------------------- /test/lib/encrypted_disk_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryption::EncryptedDiskServiceTest < ActiveSupport::TestCase 6 | def setup 7 | @storage_dir = Dir.mktmpdir 8 | @service = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir) 9 | @service.name = "amazing_encrypting_disk_service" # Needed for the DiskController and service lookup 10 | @previous_default_service = ActiveStorage::Blob.service 11 | 12 | # The EncryptedDiskService generates URLs by itself, so it needs 13 | # ActiveStorage::Current.url_options to be set 14 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 15 | # see https://stackoverflow.com/a/60573259/153886 16 | ActiveStorage::Current.url_options = { 17 | host: "www.example.com", 18 | protocol: "https" 19 | } 20 | end 21 | 22 | def teardown 23 | FileUtils.rm_rf(@storage_dir) 24 | ActiveStorage::Blob.service = @previous_default_service 25 | end 26 | 27 | def test_headers_for_direct_upload 28 | key = "key-1" 29 | k = Random.bytes(68) 30 | md5 = Digest::MD5.base64digest("x") 31 | headers = @service.headers_for_direct_upload(key, content_type: "image/jpeg", encryption_key: k, checksum: md5) 32 | assert_equal headers["x-active-storage-encryption-key"], Base64.strict_encode64(k) 33 | assert_equal headers["content-md5"], md5 34 | end 35 | 36 | def test_upload_with_checksum 37 | # We need to test this to make sure the checksum gets verified after decryption 38 | key = "key-1" 39 | k = Random.bytes(68) 40 | plaintext_upload_bytes = generate_random_binary_string 41 | 42 | incorrect_base64_md5 = Digest::MD5.base64digest("Something completely different") 43 | assert_raises(ActiveStorage::IntegrityError) do 44 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: incorrect_base64_md5) 45 | end 46 | refute @service.exist?(key) 47 | 48 | correct_base64_md5 = Digest::MD5.base64digest(plaintext_upload_bytes) 49 | assert_nothing_raised do 50 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: correct_base64_md5) 51 | end 52 | assert @service.exist?(key) 53 | end 54 | 55 | def test_put_via_controller 56 | key = "key-1" 57 | k = Random.bytes(68) 58 | plaintext_upload_bytes = generate_random_binary_string 59 | 60 | ActiveStorage::Blob.service = @service # So that the controller can find it 61 | b64md5 = Digest::MD5.base64digest(plaintext_upload_bytes) 62 | 63 | url = @service.url_for_direct_upload(key, expires_in: 60.seconds, content_type: "binary/octet-stream", content_length: plaintext_upload_bytes.bytesize, checksum: b64md5, encryption_key: k, custom_metadata: {}) 64 | assert url.include?("/active-storage-encryption/blob/") 65 | 66 | uri = URI.parse(url) 67 | # Do a super-minimalistic test on the DiskController. ActionController is actually a Rack app (or, rather: every controller action is a Rack app). 68 | # It can thus be called with a minimal Rack env. For the definition of "minimal", see https://github.com/rack/rack/blob/main/SPEC.rdoc#the-environment- 69 | rack_env = { 70 | "SCRIPT_NAME" => "", 71 | "PATH_INFO" => uri.path, 72 | "QUERY_STRING" => uri.query, 73 | "REQUEST_METHOD" => "PUT", 74 | "SERVER_NAME" => uri.host, 75 | "rack.input" => StringIO.new(plaintext_upload_bytes), 76 | "CONTENT_LENGTH" => plaintext_upload_bytes.bytesize.to_s(10), 77 | "CONTENT_TYPE" => "binary/octet-stream", 78 | "HTTP_X_ACTIVE_STORAGE_ENCRYPTION_KEY" => Base64.strict_encode64(k), 79 | "HTTP_CONTENT_MD5" => Digest::MD5.base64digest(plaintext_upload_bytes), 80 | "action_dispatch.request.parameters" => { 81 | # The controller expects the Rails router to have injected this param by extracting 82 | # it from the route path 83 | "token" => uri.path.split("/").last 84 | } 85 | } 86 | action_app = ActiveStorageEncryption::EncryptedBlobsController.action(:update) 87 | status, _headers, _body = action_app.call(rack_env) 88 | assert_equal 204, status # "Accepted" 89 | 90 | readback_bytes = @service.download(key, encryption_key: k) 91 | assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_bytes) 92 | end 93 | 94 | def test_private_url 95 | @service.private_url_policy = :stream 96 | 97 | # ActiveStorage wraps the passed filename in a wrapper thingy 98 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 99 | key = "key-1" 100 | encryption_key = Random.bytes(32) 101 | url = @service.url(key, blob_byte_size: 14, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key:, expires_in: 10.seconds) 102 | assert url.include?("/active-storage-encryption/blob/") 103 | end 104 | 105 | def test_generating_url_fails_if_streaming_is_off_for_the_service 106 | @service.private_url_policy = :disable 107 | 108 | key = "key-1" 109 | k = Random.bytes(68) 110 | plaintext_upload_bytes = generate_random_binary_string 111 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 112 | 113 | # ActiveStorage wraps the passed filename in a wrapper thingy 114 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 115 | assert_raises ActiveStorageEncryption::StreamingDisabled do 116 | @service.url(key, blob_byte_size: 12, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds) 117 | end 118 | end 119 | 120 | def test_upload_then_download_using_correct_key 121 | storage_blob_key = "key-1" 122 | k = Random.bytes(68) 123 | plaintext_upload_bytes = generate_random_binary_string 124 | 125 | assert_nothing_raised do 126 | @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 127 | end 128 | 129 | assert @service.exist?(storage_blob_key) 130 | 131 | encrypted_file_paths = Dir.glob(@storage_dir + "/**/*.encrypted-*").sort 132 | readback_encrypted_bytes = File.binread(encrypted_file_paths.last) 133 | 134 | # Make sure the output is, indeed, encrypted 135 | refute_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_encrypted_bytes) 136 | 137 | # Readback the entire file, decrypting it 138 | readback_plaintext_bytes = (+"").b 139 | @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes } 140 | assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes) 141 | 142 | # Test random access 143 | from_offset = Random.rand(0..999) 144 | chunk_size = Random.rand(0..1024) 145 | range = (from_offset..(from_offset + chunk_size)) 146 | chunk_from_upload = plaintext_upload_bytes[range] 147 | assert_equal chunk_from_upload, @service.download_chunk(storage_blob_key, range, encryption_key: k) 148 | end 149 | 150 | def test_upload_requires_key_of_certain_length 151 | storage_blob_key = "key-1" 152 | k = Random.bytes(12) 153 | plaintext_upload_bytes = generate_random_binary_string 154 | 155 | assert_raises(ArgumentError) do 156 | @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 157 | end 158 | end 159 | 160 | def test_upload_then_download_using_user_supplied_key_of_arbitrary_length 161 | storage_blob_key = "key-1" 162 | k = Random.new(Minitest.seed).bytes(128) 163 | plaintext_upload_bytes = generate_random_binary_string 164 | 165 | assert_nothing_raised do 166 | @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 167 | end 168 | assert @service.exist?(storage_blob_key) 169 | 170 | # Readback the entire file, decrypting it 171 | readback_plaintext_bytes = (+"").b 172 | @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes } 173 | assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes) 174 | end 175 | 176 | def test_upload_via_older_encryption_scheme_still_can_be_retrieved 177 | # We want to ensure that if we have a file encrypted using an older scheme (v1 in this case) it still gets picked 178 | # up by the service and decrypted correctly. 179 | rng = Random.new(Minitest.seed) 180 | encryption_key = Random.new(Minitest.seed).bytes(128) 181 | scheme = ActiveStorageEncryption::EncryptedDiskService::V1Scheme.new(encryption_key) 182 | key = rng.hex(32) 183 | 184 | # We need to make the path for the file manually. The Rails DiskService does it like this - 185 | # to make file enumeration faster: 186 | # def folder_for(key) 187 | # [ key[0..1], key[2..3] ].join("/") 188 | # end 189 | subfolder = [key[0..1], key[2..3]].join("/") 190 | subfolder_path = File.join(@storage_dir, subfolder) 191 | FileUtils.mkdir_p(subfolder_path) 192 | 193 | file_path = File.join(subfolder_path, key + ".encrypted-v1") 194 | plaintext = rng.bytes(2048) 195 | File.open(file_path, "wb") do |f| 196 | scheme.streaming_encrypt(from_plaintext_io: StringIO.new(plaintext), into_ciphertext_io: f) 197 | end 198 | 199 | # Now read it using the service. We should get the same plaintext back. 200 | readback = @service.download(key, encryption_key:) 201 | assert_equal plaintext.bytesize, readback.bytesize 202 | assert_equal plaintext[32...64], readback[32...64] 203 | end 204 | 205 | def test_composes_objects 206 | key1 = "key-1" 207 | k1 = Random.bytes(68) 208 | buf1 = generate_random_binary_string 209 | 210 | key2 = "key-2" 211 | k2 = Random.bytes(68) 212 | buf2 = generate_random_binary_string 213 | 214 | assert_nothing_raised do 215 | @service.upload(key1, StringIO.new(buf1), encryption_key: k1) 216 | @service.upload(key2, StringIO.new(buf2), encryption_key: k2) 217 | end 218 | 219 | composed_key = "key-3" 220 | k3 = Random.bytes(68) 221 | assert_nothing_raised do 222 | @service.compose([key1, key2], composed_key, source_encryption_keys: [k1, k2], encryption_key: k3) 223 | end 224 | 225 | readback_composed_bytes = @service.download(composed_key, encryption_key: k3) 226 | assert_equal Digest::SHA256.hexdigest(buf1 + buf2), Digest::SHA256.hexdigest(readback_composed_bytes) 227 | end 228 | 229 | def test_upload_then_failing_download_with_incorrect_key 230 | rng = Random.new(Minitest.seed) 231 | storage_blob_key = "key-1" 232 | k1 = rng.bytes(68) 233 | k2 = rng.bytes(68) 234 | refute_equal k1, k2 235 | 236 | plaintext_upload_bytes = generate_random_binary_string 237 | assert_nothing_raised do 238 | @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k1) 239 | end 240 | assert @service.exist?(storage_blob_key) 241 | 242 | # Readback the bytes, but use the wrong IV and key 243 | assert_raises(ActiveStorageEncryption::IncorrectEncryptionKey) do 244 | @service.download(storage_blob_key, encryption_key: k2) { |bytes| readback_plaintext_bytes << bytes } 245 | end 246 | 247 | # Readback the bytes with the correct IV and key 248 | readback_plaintext_bytes = (+"").b 249 | @service.download(storage_blob_key, encryption_key: k1) { |bytes| readback_plaintext_bytes << bytes } 250 | assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes) 251 | end 252 | 253 | def test_non_encrypted_service_goes_through_normally 254 | content = generate_random_binary_string 255 | blob = assert_nothing_raised do 256 | ActiveStorage::Blob.create_and_upload!( 257 | io: StringIO.new(content), 258 | filename: "random.text", 259 | content_type: "text/plain", 260 | service_name: "test" # use regular disk service 261 | ) 262 | end 263 | service = blob.service 264 | downloaded_blob = assert_nothing_raised do 265 | service.download(blob.key) 266 | end 267 | assert_equal content, downloaded_blob 268 | end 269 | 270 | def generate_random_binary_string(size = 17.kilobytes + 13) 271 | Random.bytes(size) 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /test/lib/encrypted_gcs_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryption::EncryptedGCSServiceTest < ActiveSupport::TestCase 6 | def config 7 | { 8 | project_id: "sandbox-ci-25b8", 9 | bucket: "sandbox-ci-testing-secure-documents", 10 | private_url_policy: "stream" 11 | } 12 | end 13 | 14 | setup do 15 | if ENV["GOOGLE_APPLICATION_CREDENTIALS"].blank? 16 | skip "You need GOOGLE_APPLICATION_CREDENTIALS set in your env and it needs to point to the JSON keyfile for GCS" 17 | end 18 | 19 | @textfile = StringIO.new("Secure document that needs to be stored encrypted.") 20 | @textfile2 = StringIO.new("While being neatly organized all in a days work aat the job.") 21 | @service = ActiveStorageEncryption::EncryptedGCSService.new(**config) 22 | @service.name = "encrypted_gcs_service" 23 | 24 | @encryption_key = ActiveStorage::Blob.generate_random_encryption_key 25 | @gcs_key_length_range = (0...ActiveStorageEncryption::EncryptedGCSService::GCS_ENCRYPTION_KEY_LENGTH_BYTES) # 32 bytes 26 | end 27 | 28 | def run_id 29 | # We use a shared GCS bucket, and multiple runs of the test suite may write into it at the same time. 30 | # To prevent clobbering and conflicts, assign a "test run ID" and mix it into the object keys. Keep that 31 | # value stable across the test suite. 32 | @test_suite_run_id ||= SecureRandom.base36(10) 33 | end 34 | 35 | def test_encrypted_question_method 36 | assert @service.encrypted? 37 | end 38 | 39 | def test_forbids_private_urls_with_disabled_policy 40 | @service.private_url_policy = :disable 41 | 42 | rng = Random.new(Minitest.seed) 43 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 44 | encryption_key = Random.bytes(68) 45 | plaintext_upload_bytes = rng.bytes(425) 46 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 47 | 48 | # ActiveStorage wraps the passed filename in a wrapper thingy 49 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 50 | 51 | assert_raises(ActiveStorageEncryption::StreamingDisabled) do 52 | @service.url(key, filename: filename_with_sanitization, blob_byte_size: plaintext_upload_bytes.bytesize, content_type: "binary/octet-stream", disposition: "inline", encryption_key:, expires_in: 10.seconds) 53 | end 54 | end 55 | 56 | def test_exists 57 | rng = Random.new(Minitest.seed) 58 | 59 | key = "#{run_id}-encrypted-exists-key-#{rng.hex(4)}" 60 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 61 | plaintext_upload_bytes = rng.bytes(1024) 62 | 63 | assert_nothing_raised { @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) } 64 | refute @service.exist?(key + "-definitely-not-present") 65 | assert @service.exist?(key) 66 | end 67 | 68 | def test_generates_private_streaming_urls_with_streaming_policy 69 | @service.private_url_policy = :stream 70 | 71 | rng = Random.new(Minitest.seed) 72 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 73 | encryption_key = Random.bytes(68) 74 | plaintext_upload_bytes = rng.bytes(425) 75 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 76 | 77 | # The streaming URL generation uses Rails routing, so it needs 78 | # ActiveStorage::Current.url_options to be set 79 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 80 | # see https://stackoverflow.com/a/60573259/153886 81 | ActiveStorage::Current.url_options = { 82 | host: "www.example.com", 83 | protocol: "https" 84 | } 85 | 86 | # ActiveStorage wraps the passed filename in a wrapper thingy 87 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 88 | url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize, 89 | filename: filename_with_sanitization, content_type: "binary/octet-stream", 90 | disposition: "inline", encryption_key:, expires_in: 10.seconds) 91 | assert url.include?("/active-storage-encryption/blob/") 92 | end 93 | 94 | def test_generates_private_urls_with_require_headers_policy 95 | @service.private_url_policy = :require_headers 96 | 97 | rng = Random.new(Minitest.seed) 98 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 99 | encryption_key = Random.bytes(68) 100 | plaintext_upload_bytes = rng.bytes(425) 101 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 102 | 103 | # ActiveStorage wraps the passed filename in a wrapper thingy 104 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 105 | url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize, 106 | filename: filename_with_sanitization, content_type: "binary/octet-stream", 107 | disposition: "inline", encryption_key:, expires_in: 240.seconds) 108 | 109 | query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h 110 | 111 | # Downcased header names for this test since that's what we get back from signing process. 112 | expected_headers = ["x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"] 113 | signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";") 114 | assert expected_headers.all? { |header| header.in?(signed_headers) } 115 | 116 | uri = URI(url) 117 | req = Net::HTTP::Get.new(uri) 118 | res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| 119 | http.request(req) 120 | } 121 | assert_equal "400", res.code 122 | 123 | # TODO make this a headers_for_private_download like in the s3 service 124 | download_headers = { 125 | "content-type" => "binary/octet-stream", 126 | "Content-Disposition" => "inline; filename=\"temp.bin\"; filename*=UTF-8''temp.bin", 127 | "x-goog-encryption-algorithm" => "AES256", 128 | "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]), 129 | "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range]) 130 | } 131 | download_headers.each_pair { |key, value| req[key] = value } 132 | 133 | res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| 134 | http.request(req) 135 | } 136 | assert_equal "200", res.code 137 | assert_equal plaintext_upload_bytes, res.body 138 | end 139 | 140 | def test_basic_gcs_readback 141 | rng = Random.new(Minitest.seed) 142 | 143 | key = "#{run_id}-encrypted-key-#{rng.hex(4)}" 144 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 145 | plaintext_upload_bytes = rng.bytes(1024) 146 | 147 | assert_nothing_raised do 148 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 149 | end 150 | readback = @service.download(key, encryption_key:) 151 | assert_equal readback, plaintext_upload_bytes 152 | end 153 | 154 | def test_accepts_direct_upload_with_signature_and_headers 155 | rng = Random.new(Minitest.seed) 156 | 157 | key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}" 158 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 159 | plaintext_upload_bytes = rng.bytes(1024) 160 | 161 | url = @service.url_for_direct_upload(key, 162 | encryption_key:, 163 | expires_in: 5.minutes.to_i, 164 | content_type: "binary/octet-stream", 165 | content_length: plaintext_upload_bytes.bytesize, 166 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 167 | 168 | query_params_hash = URI.decode_www_form(URI.parse(url).query).to_h 169 | 170 | # Downcased header names for this test since that's what we get back from signing process. 171 | expected_headers = ["content-md5", "x-goog-encryption-algorithm", "x-goog-encryption-key", "x-goog-encryption-key-sha256"] 172 | signed_headers = query_params_hash["X-Goog-SignedHeaders"].split(";") 173 | assert expected_headers.all? { |header| header.in?(signed_headers) } 174 | 175 | assert_equal "300", query_params_hash["X-Goog-Expires"] 176 | 177 | should_be_headers = { 178 | "Content-Type" => "binary/octet-stream", 179 | "Content-MD5" => Digest::MD5.base64digest(plaintext_upload_bytes), 180 | "x-goog-encryption-algorithm" => "AES256", 181 | "x-goog-encryption-key" => Base64.strict_encode64(encryption_key[@gcs_key_length_range]), 182 | "x-goog-encryption-key-sha256" => Digest::SHA256.base64digest(encryption_key[@gcs_key_length_range]) 183 | } 184 | 185 | headers = @service.headers_for_direct_upload(key, 186 | encryption_key:, 187 | content_type: "binary/octet-stream", 188 | content_length: plaintext_upload_bytes.bytesize, 189 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 190 | 191 | assert_equal should_be_headers.sort, headers.sort 192 | 193 | res = Net::HTTP.put(URI(url), plaintext_upload_bytes, headers) 194 | assert_equal "200", res.code 195 | 196 | assert_equal plaintext_upload_bytes, @service.download(key, encryption_key:) 197 | 198 | @service.delete(key) 199 | refute @service.exist?(key) 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /test/lib/encrypted_mirror_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ActiveStorageEncryption::EncryptedMirrorServiceTest < ActiveSupport::TestCase 6 | include ActiveJob::TestHelper 7 | 8 | def setup 9 | @storage_dir = Dir.mktmpdir 10 | 11 | @service1 = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir + "/primary-encrypted") 12 | @service2 = ActiveStorage::Service::DiskService.new(root: @storage_dir + "/secondary-plain") 13 | @service3 = ActiveStorageEncryption::EncryptedDiskService.new(root: @storage_dir + "/secondary-encrypted") 14 | 15 | @service = ActiveStorageEncryption::EncryptedMirrorService.new(primary: @service1, mirrors: [@service2, @service3]) 16 | @service.name = "amazing_mirror_service" # Needed for service lookup 17 | @previous_default_service = ActiveStorage::Blob.service 18 | 19 | # The EncryptedDiskService generates URLs by itself, so it needs 20 | # ActiveStorage::Current.url_options to be set 21 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 22 | # see https://stackoverflow.com/a/60573259/153886 23 | ActiveStorage::Current.url_options = { 24 | host: "www.example.com", 25 | protocol: "https" 26 | } 27 | end 28 | 29 | def teardown 30 | FileUtils.rm_rf(@storage_dir) 31 | ActiveStorage::Blob.service = @previous_default_service 32 | end 33 | 34 | def test_headers_for_direct_upload 35 | key = "key-1" 36 | k = Random.bytes(68) 37 | md5 = Digest::MD5.base64digest("x") 38 | headers = @service.headers_for_direct_upload(key, content_type: "image/jpeg", encryption_key: k, checksum: md5) 39 | assert_equal headers["x-active-storage-encryption-key"], Base64.strict_encode64(k) 40 | assert_equal headers["content-md5"], md5 41 | end 42 | 43 | def test_upload_with_checksum 44 | # We need to test this to make sure the checksum gets verified after decryption 45 | key = "key-1" 46 | k = Random.bytes(68) 47 | plaintext_upload_bytes = generate_random_binary_string 48 | 49 | incorrect_base64_md5 = Digest::MD5.base64digest("Something completely different") 50 | assert_raises(ActiveStorage::IntegrityError) do 51 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: incorrect_base64_md5) 52 | end 53 | refute @service.exist?(key) 54 | 55 | correct_base64_md5 = Digest::MD5.base64digest(plaintext_upload_bytes) 56 | assert_nothing_raised do 57 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k, checksum: correct_base64_md5) 58 | end 59 | assert @service.exist?(key) 60 | end 61 | 62 | def test_uploads_to_primary_and_mirrors_to_secondaries 63 | # So that the job can find our service 64 | ActiveStorage::Blob.service = @service 65 | 66 | key = "key-1" 67 | k = Random.bytes(68) 68 | plaintext_upload_bytes = Random.bytes(42) 69 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 70 | 71 | assert @service.exist?(key) 72 | assert @service1.exist?(key) # Primary 73 | refute @service2.exist?(key) 74 | refute @service3.exist?(key) 75 | 76 | perform_enqueued_jobs 77 | 78 | assert @service2.exist?(key) 79 | assert @service3.exist?(key) 80 | assert_equal plaintext_upload_bytes, @service2.download(key) 81 | assert_equal plaintext_upload_bytes, @service3.download(key, encryption_key: k) 82 | end 83 | 84 | def test_generates_direct_upload_url_for_primary 85 | key = "key-1" 86 | k = Random.bytes(68) 87 | plaintext_upload_bytes = generate_random_binary_string 88 | 89 | ActiveStorage::Blob.service = @service # So that the controller can find it 90 | b64md5 = Digest::MD5.base64digest(plaintext_upload_bytes) 91 | 92 | url = @service.url_for_direct_upload(key, expires_in: 60.seconds, content_type: "binary/octet-stream", content_length: plaintext_upload_bytes.bytesize, checksum: b64md5, encryption_key: k, custom_metadata: {}) 93 | assert url.include?("/active-storage-encryption/blob/") 94 | end 95 | 96 | def passes_through_private_url_policy_from_primary 97 | @service1.private_url_policy = :disable 98 | assert_equal :disable, @service.private_url_policy 99 | 100 | @service1.private_url_policy = :stream 101 | assert_equal :stream, @service.private_url_policy 102 | end 103 | 104 | def test_does_not_accept_private_url_policy 105 | assert_raises(ArgumentError) do 106 | @service.private_url_policy = :stream 107 | end 108 | end 109 | 110 | def test_get_without_headers_succeeds_if_service_permits 111 | @service1.private_url_policy = :stream 112 | 113 | key = "key-1" 114 | k = Random.bytes(68) 115 | plaintext_upload_bytes = generate_random_binary_string 116 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 117 | 118 | ActiveStorage::Blob.service = @service # So that the controller can find it 119 | 120 | # ActiveStorage wraps the passed filename in a wrapper thingy 121 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 122 | url = @service.url(key, blob_byte_size: 13, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds) 123 | assert url.include?("/active-storage-encryption/blob/") 124 | end 125 | 126 | def test_upload_then_download_using_correct_key 127 | storage_blob_key = "key-1" 128 | k = Random.bytes(68) 129 | plaintext_upload_bytes = generate_random_binary_string 130 | 131 | assert_nothing_raised do 132 | @service.upload(storage_blob_key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 133 | end 134 | 135 | assert @service.exist?(storage_blob_key) 136 | 137 | encrypted_file_paths = Dir.glob(@storage_dir + "/**/*.encrypted-*").sort 138 | readback_encrypted_bytes = File.binread(encrypted_file_paths.last) 139 | 140 | # Make sure the output is, indeed, encrypted 141 | refute_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_encrypted_bytes) 142 | 143 | # Readback the entire file, decrypting it 144 | readback_plaintext_bytes = (+"").b 145 | @service.download(storage_blob_key, encryption_key: k) { |bytes| readback_plaintext_bytes << bytes } 146 | assert_equal Digest::SHA256.hexdigest(plaintext_upload_bytes), Digest::SHA256.hexdigest(readback_plaintext_bytes) 147 | 148 | # Test random access 149 | from_offset = Random.rand(0..999) 150 | chunk_size = Random.rand(0..1024) 151 | range = (from_offset..(from_offset + chunk_size)) 152 | chunk_from_upload = plaintext_upload_bytes[range] 153 | assert_equal chunk_from_upload, @service.download_chunk(storage_blob_key, range, encryption_key: k) 154 | end 155 | 156 | def generate_random_binary_string(size = 17.kilobytes + 13) 157 | Random.bytes(size) 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /test/lib/encrypted_s3_service_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "net/http" 5 | 6 | class ActiveStorageEncryption::EncryptedS3ServiceTest < ActiveSupport::TestCase 7 | def config 8 | { 9 | access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"), 10 | secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"), 11 | region: "eu-central-1", 12 | bucket: "active-storage-encryption-test-bucket" 13 | } 14 | end 15 | 16 | setup do 17 | if ENV["AWS_ACCESS_KEY_ID"].blank? || ENV["AWS_SECRET_ACCESS_KEY"].blank? 18 | skip "You need AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY set in your env to test the EncryptedS3Service" 19 | end 20 | end 21 | 22 | setup do 23 | require "active_storage/service/s3_service" 24 | @service = ActiveStorageEncryption::EncryptedS3Service.new(**config) 25 | @service.name = "amazing_encrypting_s3_service" # Needed for the DiskController and service lookup 26 | end 27 | 28 | def run_id 29 | # We use a shared S3 bucket, and multiple runs of the test suite may write into it at the same time. 30 | # To prevent clobbering and conflicts, assign a "test run ID" and mix it into the object keys. Keep that 31 | # value stable across the test suite. 32 | @test_suite_run_id ||= SecureRandom.base36(10) 33 | end 34 | 35 | def test_encrypted_question_method 36 | assert @service.encrypted? 37 | end 38 | 39 | def test_forbids_private_urls_with_disabled_policy 40 | @service.private_url_policy = :disable 41 | 42 | rng = Random.new(Minitest.seed) 43 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 44 | k = Random.bytes(68) 45 | plaintext_upload_bytes = rng.bytes(425) 46 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 47 | 48 | # ActiveStorage wraps the passed filename in a wrapper thingy 49 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 50 | 51 | assert_raises(ActiveStorageEncryption::StreamingDisabled) do 52 | @service.url(key, filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 10.seconds) 53 | end 54 | end 55 | 56 | def test_generates_private_streaming_urls_with_streaming_policy 57 | @service.private_url_policy = :stream 58 | 59 | rng = Random.new(Minitest.seed) 60 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 61 | k = Random.bytes(68) 62 | plaintext_upload_bytes = rng.bytes(425) 63 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 64 | 65 | # The streaming URL generation uses Rails routing, so it needs 66 | # ActiveStorage::Current.url_options to be set 67 | # We need to use a hostname for ActiveStorage which is in the Rails authorized hosts. 68 | # see https://stackoverflow.com/a/60573259/153886 69 | ActiveStorage::Current.url_options = { 70 | host: "www.example.com", 71 | protocol: "https" 72 | } 73 | 74 | # ActiveStorage wraps the passed filename in a wrapper thingy 75 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 76 | url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize, 77 | filename: filename_with_sanitization, content_type: "binary/octet-stream", 78 | disposition: "inline", encryption_key: k, expires_in: 10.seconds) 79 | assert url.include?("/active-storage-encryption/blob/") 80 | end 81 | 82 | def test_generates_private_urls_with_require_headers_policy 83 | @service.private_url_policy = :require_headers 84 | 85 | rng = Random.new(Minitest.seed) 86 | key = "#{run_id}-streamed-key-#{rng.hex(4)}" 87 | k = Random.bytes(68) 88 | plaintext_upload_bytes = rng.bytes(425) 89 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key: k) 90 | 91 | # ActiveStorage wraps the passed filename in a wrapper thingy 92 | filename_with_sanitization = ActiveStorage::Filename.new("temp.bin") 93 | url = @service.url(key, blob_byte_size: plaintext_upload_bytes.bytesize, 94 | filename: filename_with_sanitization, content_type: "binary/octet-stream", disposition: "inline", encryption_key: k, expires_in: 240.seconds) 95 | 96 | assert url.include?("x-amz-server-side-encryption-customer-algorithm") 97 | refute url.include?("x-amz-server-side-encryption-customer-key=") # The key should not be in the URL 98 | 99 | uri = URI(url) 100 | req = Net::HTTP::Get.new(uri) 101 | res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| 102 | http.request(req) 103 | } 104 | assert_equal "400", res.code 105 | 106 | headers = @service.headers_for_private_download(key, encryption_key: k) 107 | headers.each_pair do |h, v| 108 | req[h] = v 109 | end 110 | 111 | res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| 112 | http.request(req) 113 | } 114 | assert_equal "200", res.code 115 | assert_equal plaintext_upload_bytes, res.body 116 | end 117 | 118 | def test_s3_config_sane_and_works_with_stock_service 119 | # maybe remove later 120 | stock_s3_service = ActiveStorage::Service::S3Service.new(**config) 121 | rng = Random.new(Minitest.seed) 122 | key = "#{run_id}-unencrypted-key-#{rng.hex(4)}" 123 | plaintext_upload_bytes = rng.bytes(1024) 124 | assert_nothing_raised do 125 | stock_s3_service.upload(key, StringIO.new(plaintext_upload_bytes)) 126 | end 127 | readback = stock_s3_service.download(key) 128 | assert_equal readback, plaintext_upload_bytes 129 | end 130 | 131 | def test_exists 132 | rng = Random.new(Minitest.seed) 133 | 134 | key = "#{run_id}-encrypted-exists-key-#{rng.hex(4)}" 135 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 136 | plaintext_upload_bytes = rng.bytes(1024) 137 | 138 | assert_nothing_raised { @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) } 139 | refute @service.exist?(key + "-definitely-not-present") 140 | assert @service.exist?(key) 141 | end 142 | 143 | def test_basic_s3_readback 144 | rng = Random.new(Minitest.seed) 145 | 146 | key = "#{run_id}-encrypted-key-#{rng.hex(4)}" 147 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 148 | plaintext_upload_bytes = rng.bytes(1024) 149 | 150 | assert_nothing_raised do 151 | @service.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 152 | end 153 | readback = @service.download(key, encryption_key:) 154 | assert_equal readback, plaintext_upload_bytes 155 | end 156 | 157 | def test_s3_upload_requiring_multipart 158 | rng = Random.new(Minitest.seed) 159 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 160 | 161 | # The minimum multipart part size is 5MB 162 | multipart_threshold = 1024 * 1024 * 5 163 | total_size = multipart_threshold + 3 164 | plaintext_upload_bytes = rng.bytes(total_size) 165 | 166 | key = "#{run_id}-encrypted-key-#{rng.hex(4)}" 167 | service_with_smaller_part_size = ActiveStorageEncryption::EncryptedS3Service.new(**config, upload: {multipart_threshold:}) 168 | assert_nothing_raised do 169 | service_with_smaller_part_size.upload(key, StringIO.new(plaintext_upload_bytes), encryption_key:) 170 | end 171 | 172 | readback = service_with_smaller_part_size.download(key, encryption_key:) 173 | assert_equal total_size, readback.bytesize 174 | end 175 | 176 | def test_accepts_direct_upload_with_signature_and_headers 177 | rng = Random.new(Minitest.seed) 178 | 179 | key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}" 180 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 181 | plaintext_upload_bytes = rng.bytes(1024) 182 | 183 | url = @service.url_for_direct_upload(key, 184 | encryption_key:, 185 | expires_in: 1.minute, 186 | content_type: "binary/octet-stream", 187 | content_length: plaintext_upload_bytes.bytesize, 188 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 189 | headers = @service.headers_for_direct_upload(key, 190 | encryption_key:, 191 | content_type: "binary/octet-stream", 192 | content_length: plaintext_upload_bytes.bytesize, 193 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 194 | 195 | refute url.include?("x-amz-server-side-encryption-customer-key=") # The key should not be in the URL 196 | assert url.include?("x-amz-server-side-encryption-customer-key-md5=") # The checksum must be in the URL 197 | 198 | res = Net::HTTP.put(URI(url), plaintext_upload_bytes, headers) 199 | assert_equal "200", res.code 200 | 201 | assert_equal plaintext_upload_bytes, @service.download(key, encryption_key:) 202 | end 203 | 204 | def test_rejects_direct_upload_if_client_manipulates_the_encryption_key 205 | skip "Currently does not work, investigate" 206 | 207 | rng = Random.new(Minitest.seed) 208 | 209 | key = "#{run_id}-encrypted-key-direct-upload-#{rng.hex(4)}" 210 | encryption_key = rng.bytes(47) # Make it bigger than required, to ensure the service truncates it 211 | plaintext_upload_bytes = rng.bytes(1024) 212 | 213 | url = @service.url_for_direct_upload(key, 214 | encryption_key:, 215 | expires_in: 1.minute, 216 | content_type: "binary/octet-stream", 217 | content_length: plaintext_upload_bytes.bytesize, 218 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 219 | headers = @service.headers_for_direct_upload(key, 220 | encryption_key:, 221 | content_type: "binary/octet-stream", 222 | content_length: plaintext_upload_bytes.bytesize, 223 | checksum: Digest::MD5.base64digest(plaintext_upload_bytes)) 224 | 225 | # Replace the key and its checksum 226 | other_key = Random.bytes(32) 227 | fake_headers = headers.merge({ 228 | "x-amz-server-side-encryption-customer-key" => Base64.strict_encode64(other_key), 229 | "x-amz-server-side-encryption-customer-key-MD5" => Digest::MD5.base64digest(other_key) 230 | }) 231 | res = Net::HTTP.put(URI(url), plaintext_upload_bytes, fake_headers) 232 | refute_equal "200", res.code 233 | end 234 | 235 | # Read the objects from something slow, so that threads may switch between one another 236 | class SnoozyStringIO < StringIO 237 | def read(n = nil, outbuf = nil) 238 | sleep_from = 0.1 239 | sleep_to = 0.2 240 | delay_s = rand(sleep_from..sleep_to) 241 | sleep(delay_s) 242 | super 243 | end 244 | end 245 | 246 | def test_uploads_correctly_across_multiple_threads 247 | # Due to a hack that we are applying to reuse most of the stock S3Service, we 248 | # temporarily override @upload_options on the service when an upload is in progress. 249 | # This must be done in a thread-local manner, otherwise some uploads may, potentially, 250 | # get uploaded with the wrong encryption key - belonging to an upload from a different 251 | # thread. While a test like this is by no means exhaustive, it should reveal this 252 | # race condition if it occurs. 253 | rng = Random.new(Minitest.seed) 254 | objects = 12.times.map do |n| 255 | key = "#{run_id}-threaded-upload-#{n}-#{rng.hex(4)}" 256 | encryption_key = rng.bytes(32) 257 | bytes = rng.bytes(512) 258 | {key:, encryption_key:, io: SnoozyStringIO.new(bytes)} 259 | end 260 | 261 | threads = objects.map do |o| 262 | Thread.new do 263 | @service.upload(o.fetch(:key), o.fetch(:io), encryption_key: o.fetch(:encryption_key)) 264 | end 265 | end 266 | threads.map(&:join) 267 | 268 | objects.each do |o| 269 | readback = @service.download(o.fetch(:key), encryption_key: o.fetch(:encryption_key)) 270 | assert_equal o.fetch(:io).string, readback 271 | end 272 | end 273 | 274 | def test_composes_objects 275 | rng = Random.new(Minitest.seed) 276 | 277 | key1 = "#{run_id}-to-compose-key-1-#{rng.hex(4)}" 278 | k1 = rng.bytes(68) 279 | buf1 = rng.bytes(1024 * 7) 280 | 281 | key2 = "#{run_id}-to-compose-key-2-#{rng.hex(4)}" 282 | k2 = rng.bytes(68) 283 | buf2 = rng.bytes(1024 * 3) 284 | 285 | assert_nothing_raised do 286 | @service.upload(key1, StringIO.new(buf1), encryption_key: k1) 287 | @service.upload(key2, StringIO.new(buf2), encryption_key: k2) 288 | end 289 | 290 | composed_key = "#{run_id}-composed-key-3-#{rng.hex(4)}" 291 | k3 = Random.bytes(68) 292 | assert_nothing_raised do 293 | @service.compose([key1, key2], composed_key, source_encryption_keys: [k1, k2], encryption_key: k3, content_type: "binary/octet-stream") 294 | end 295 | 296 | readback_composed_bytes = @service.download(composed_key, encryption_key: k3) 297 | assert_equal Digest::SHA256.hexdigest(buf1 + buf2), Digest::SHA256.hexdigest(readback_composed_bytes) 298 | end 299 | end 300 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure Rails Environment 4 | ENV["RAILS_ENV"] = "test" 5 | 6 | require_relative "../test/dummy/config/environment" 7 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 8 | ActiveRecord::Migrator.migrations_paths << File.expand_path("../db/migrate", __dir__) 9 | require "rails/test_help" 10 | 11 | require "minitest/mock" 12 | 13 | # Load fixtures from the engine 14 | if ActiveSupport::TestCase.respond_to?(:fixture_paths=) 15 | ActiveSupport::TestCase.fixture_paths = [File.expand_path("fixtures", __dir__)] 16 | ActionDispatch::IntegrationTest.fixture_paths = ActiveSupport::TestCase.fixture_paths 17 | ActiveSupport::TestCase.file_fixture_path = File.expand_path("fixtures", __dir__) + "/files" 18 | ActiveSupport::TestCase.fixtures :all 19 | end 20 | --------------------------------------------------------------------------------