├── .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 |You may have mistyped the address or the page may have moved.
63 |If you are the application owner check the logs for more information.
65 |Please upgrade your browser to continue.
63 |Maybe you tried to change something you didn't have access to.
63 |If you are the application owner check the logs for more information.
65 |If you are the application owner check the logs for more information.
64 |