├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .yardopts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Guardfile ├── LICENCE ├── README.md ├── Rakefile ├── active_enquo.gemspec ├── docs ├── DEVELOPMENT.md └── MIGRATION.md ├── e2e_tests ├── .gitignore ├── 001_direct_migration │ ├── exercise_model │ ├── migrations │ │ ├── 001_create_people_table.rb │ │ └── 002_encrypt_people_data.rb │ └── run ├── helper.sh ├── init.rb ├── people.json └── run ├── lib └── active_enquo.rb └── spec ├── bigint ├── insertion_spec.rb └── retrieval_spec.rb ├── boolean ├── insertion_spec.rb └── retrieval_spec.rb ├── date ├── insertion_spec.rb └── retrieval_spec.rb ├── spec_helper.rb ├── support ├── migrations │ ├── create_bigints.rb │ ├── create_booleans.rb │ ├── create_dates.rb │ └── create_texts.rb └── models │ ├── bigint.rb │ ├── boolean.rb │ ├── date.rb │ └── text.rb └── text ├── insertion_spec.rb └── retrieval_spec.rb /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.{yml,yaml}] 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.rb] 14 | indent_style = tab 15 | indent_size = 3 16 | 17 | [Rakefile] 18 | indent_style = tab 19 | indent_size = 3 20 | 21 | [*.md] 22 | indent_style = space 23 | indent_size = 2 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: "Release to RubyGems" 2 | on: 3 | release: 4 | types: [created] 5 | workflow_dispatch: 6 | 7 | jobs: 8 | upload: 9 | runs-on: ubuntu-latest 10 | name: "Upload" 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Install ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: '2.7' 21 | bundler-cache: true 22 | 23 | - name: Workaround for https://github.com/actions/checkout/issues/290 24 | run: | 25 | git fetch --force --tags 26 | 27 | - name: Do The Needful 28 | env: 29 | GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} 30 | run: | 31 | rake release 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | /pkg 3 | /doc 4 | /.yardoc 5 | /coverage 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of 4 | fostering an open and welcoming community, we pledge to respect all people who 5 | contribute through reporting issues, posting feature requests, updating 6 | documentation, submitting pull requests or patches, and other activities. 7 | 8 | We are committed to making participation in this project a harassment-free 9 | experience for everyone, regardless of level of experience, gender, gender 10 | identity and expression, sexual orientation, disability, personal appearance, 11 | body size, race, ethnicity, age, religion, or nationality. 12 | 13 | Examples of unacceptable behavior by participants include: 14 | 15 | * The use of sexualized language or imagery 16 | * Personal attacks 17 | * Trolling or insulting/derogatory comments 18 | * Public or private harassment 19 | * Publishing other's private information, such as physical or electronic 20 | addresses, without explicit permission 21 | * Other unethical or unprofessional conduct 22 | 23 | Project maintainers have the right and responsibility to remove, edit, or 24 | reject comments, commits, code, wiki edits, issues, and other contributions 25 | that are not aligned to this Code of Conduct, or to ban temporarily or 26 | permanently any contributor for other behaviors that they deem inappropriate, 27 | threatening, offensive, or harmful. 28 | 29 | By adopting this Code of Conduct, project maintainers commit themselves to 30 | fairly and consistently applying these principles to every aspect of managing 31 | this project. Project maintainers who do not follow or enforce the Code of 32 | Conduct may be permanently removed from the project team. 33 | 34 | This code of conduct applies both within project spaces and in public spaces 35 | when an individual is representing the project or its community. 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 38 | reported by contacting a project maintainer at coc@enquo.org. All complaints 39 | will be reviewed and investigated and will result in a response that is deemed 40 | necessary and appropriate to the circumstances. Maintainers are obligated to 41 | maintain confidentiality with regard to the reporter of an incident. 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 44 | version 1.3.0, available at 45 | [http://contributor-covenant.org/version/1/3/0/][version] 46 | 47 | [homepage]: http://contributor-covenant.org 48 | [version]: http://contributor-covenant.org/version/1/3/0/ 49 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | * If you have found a discrepancy in documented and observed behaviour, that is a bug. 2 | Feel free to [report it as an issue](https://github.com/enquo/active_enquo/issues), providing sufficient detail to reproduce the problem. 3 | 4 | * If you would like to add new behaviour, please submit a well-tested and well-documented [pull request](https://github.com/enquo/active_enquo/pulls). 5 | 6 | * By making a contribution to this repository, you agree that the contribution is licenced under the terms of the [MIT licence](./LICENCE). 7 | Further, you warrant that you hold all intellectual property rights in the contribution, or that you have the permission of the owner of those rights to make the contribution under these conditions. 8 | 9 | * At all times, abide by the Code of Conduct (CODE_OF_CONDUCT.md). 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org/' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', 2 | :cmd => "bundle exec rspec", 3 | :all_on_start => true, 4 | :all_after_pass => true do 5 | watch(%r{^spec/.+_spec\.rb$}) 6 | watch(%r{^spec/.+_methods\.rb$}) 7 | watch(%r{^lib/}) { "spec" } 8 | end 9 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [ActiveEnquo](https://enquo.org/active_enquo) is a Ruby on Rails ActiveRecord extension that works with the [pg_enquo Postgres extension](https://github.com/enquo/pg_enquo) to allow you to query encrypted data. 2 | This allows you to keep the data you store safe, by encrypting it, without compromising on your application's ability to search for that data and work with it. 3 | 4 | Sounds like magic? 5 | Well, maybe a little bit. 6 | Read our [how it works](https://enquo.org/how-it-works) if you're interested in the gory cryptographic details, or read on for how to use it. 7 | 8 | 9 | # Pre-requisites 10 | 11 | In order to make use of ActiveRecord extension, you must be running Postgres 11 or higher, with the [`pg_enquo`](https://github.com/enquo/pg_enquo) extension enabled in the database you're working in. 12 | See [the `pg_enquo` installation guide](https://github.com/enquo/pg_enquo/tree/main/doc/installation.md) for instructions on how to install `pg_enquo`. 13 | 14 | Also, if you're installing this gem from source, you'll need a reasonably recent [Rust](https://rust-lang.org) toolchain installed. 15 | 16 | 17 | # Installation 18 | 19 | It's a gem, so the usual methods should work Just Fine: 20 | 21 | ```sh 22 | gem install active_enquo 23 | # OR 24 | echo "gem 'active_enquo'" >> Gemfile 25 | ``` 26 | 27 | On macOS, and Linux `x86-64`/`aarch64`, you'll get a pre-built binary gem that contains everything you need. 28 | For other platforms, you'll need to have Rust 1.59.0 or later installed in order to build the native code portion of the gem. 29 | 30 | 31 | # Configuration 32 | 33 | The only setting that ActiveEnquo needs is to be given a "root" key, which is used to derive the keys which are used to actually encrypt data. 34 | 35 | 36 | ## Step 1: Generate a Root Key 37 | 38 | The ActiveEnquo root key ***MUST*** be generated by a cryptographically-secure random number generator, and must also be 64 hex digits in length. 39 | A good way to generate this key is with the `SecureRandom` module: 40 | 41 | ```sh 42 | ruby -r securerandom -e 'puts SecureRandom.hex(32)' 43 | ``` 44 | 45 | 46 | ## Step 2: Configure Your Application 47 | 48 | With this key in hand, you need to store it somewhere. 49 | 50 | 51 | ### Using Rails Credential Store (Recommended) 52 | 53 | The recommended way to store your root key, at present, is in the [Rails credentials store](https://guides.rubyonrails.org/security.html#custom-credentials). 54 | 55 | 1. Open up the Rails credentials editor: 56 | 57 | ```ruby 58 | rails credentials:edit 59 | ``` 60 | 61 | 2. Add a section to that file that looks like this: 62 | 63 | ```yaml 64 | active_enquo: 65 | root_key: "0000000000000000000000000000000000000000000000000000000000000000" 66 | ``` 67 | 68 | 3. Save and exit the editor. Commit the changes to your revision control system. 69 | 70 | 71 | ### Direct Assignment (Only If You Must) 72 | 73 | Using the Rails credential store only works if you are using Rails, of course. 74 | If you're using ActiveRecord by itself, you must set the root key yourself during application initialization. 75 | You do this by assigning a `RootKey` to `ActiveEnquo.root_key`, like this: 76 | 77 | ```ruby 78 | # DO NOT ACTUALLY PUT YOUR KEY DIRECTLY IN YOUR CODE!!! 79 | ActiveEnquo.root_key = ActiveEnquo::RootKey::Static.new("0000000000000000000000000000000000000000000000000000000000000000") 80 | ``` 81 | 82 | Preferably, you would pass the key into your application via, say, an environment variable, and then immediately clear the environment variable: 83 | 84 | ```ruby 85 | ActiveEnquo.root_key = ActiveEnquo::RootKey::Static.new(ENV.fetch("ENQUO_ROOT_KEY")) 86 | ENV.delete("ENQUO_ROOT_KEY") 87 | ``` 88 | 89 | Support for cloud keystores, such as AWS KMS, GCP KMS, Azure KeyVault, and HashiCorp Vault, will be implemented sooner or later. 90 | If you have a burning desire to see that more on the "sooner" end than "later", PRs are welcome. 91 | 92 | 93 | # Usage 94 | 95 | We try to make using ActiveEnquo as simple as possible. 96 | 97 | 98 | ## Create Your Encrypted Column 99 | 100 | Start by creating a column in your database that uses one of the [available `enquo_*` types](https://github.com/enquo/pg_enquo/tree/main/doc/data_types), with a Rails migration: 101 | 102 | ```ruby 103 | class AddEncryptedBigintColumn < ActiveRecord::Migration[6.0] 104 | def change 105 | add_column :users, :date_of_birth, :enquo_date 106 | end 107 | end 108 | ``` 109 | 110 | Apply this migration in the usual fashion (`rails db:migrate`). 111 | 112 | 113 | ## Reading and Writing 114 | 115 | You can now, without any further ado, use that attribute in your models as you would normally. 116 | For example, you can insert a new record: 117 | 118 | ```ruby 119 | User.create!([{name: "Clara Bloggs", username: "cbloggs", date_of_birth: Date.new(1970, 1, 1)}]) 120 | ``` 121 | 122 | When you retrieve a record, the value is there for you to read: 123 | 124 | ```ruby 125 | User.where(username: "cbloggs").first.date_of_birth.to_s # => "1970-01-01" 126 | ``` 127 | 128 | 129 | ## Querying 130 | 131 | This is where things get *neat*. 132 | 133 | Performing a query on Enquo-encrypted data is done the same way as on unencrypted data. 134 | 135 | You can query for records that have the exact value you're looking for: 136 | 137 | ```ruby 138 | User.where(date_of_birth: Date(1970, 1, 1)) 139 | ``` 140 | 141 | Or you can query for users born less than 50 years ago: 142 | 143 | ```ruby 144 | User.where(date_of_birth: (Date.today - 50.years))..) 145 | ``` 146 | 147 | This doesn't seem so magical, until you take a peek in the database, and realise that *all the data is still encrypted*: 148 | 149 | ```sh 150 | psql> SELECT date_of_birth FROM users WHERE username='cbloggs'; 151 | age 152 | ------- 153 | {"v1":{"a":[],"y":[],}} 154 | ``` 155 | 156 | 157 | ## Migrating Existing Data to Encrypted Form 158 | 159 | This is a topic on which a lot of words can be written. 160 | For the sake of tidiness, these words are in [a guide of their own](docs/MIGRATION.md). 161 | 162 | 163 | ## Indexing and Ordering 164 | 165 | To maintain [security by default](https://enquo.org/about/threat-models#snapshot-security), ActiveEnquo doesn't provide the ability to `ORDER BY` or index columns by default. 166 | This is fine for many situations -- many columns don't need indexes or to be ordered in a query. 167 | 168 | For those columns that *do* need indexes or `ORDER BY` support, you can enable support for them by setting the `enable_reduced_security_operations` flag on the attribute, like this: 169 | 170 | ```ruby 171 | class User < ApplicationRecord 172 | # Enables indexing and ORDER BY for this column, at the cost of reduced security 173 | enquo_attr :age, enable_reduced_security_operations: true 174 | end 175 | ``` 176 | 177 | 178 | ### Security Considerations 179 | 180 | As the name implies, "reduced security operations" require that the security of the data in the column be lower than [Enquo's default security properties](https://enquo.org/about/threat-models#snapshot-security). 181 | Specifically, extra data needs to be stored in the value to enable indexing and ordering. 182 | This extra data can be used by an attacker to: 183 | 184 | * Identify all rows which have the same value for the column (although not what that value actually *is*); and 185 | 186 | * Perform inference attacks to try and determine the approximate or exact value for the column of some or all of the rows. 187 | 188 | The practical implications of these attack vectors varies wildly between different types of data, which makes it harder to decide if it's reasonable to allow reduced security operations. 189 | Our recommended rule-of-thumb is that if the features you need can *only* be implemented if you either enable reduced security operations, or leave the data unencrypted, then enable them. 190 | Otherwise, leave the default as-is. 191 | 192 | 193 | ## Saving Disk Space 194 | 195 | While the power of `ActiveEnquo` is based around being able to *query* encrypted data, not all columns necessarily need to be queried. 196 | If so, you can reduce the disk space requirements for those columns by setting `no_query: true` for those columns: 197 | 198 | ```ruby 199 | class User < ApplicationRecord 200 | # Disables querying for this column, and saves a heap of bytes on your disk space 201 | enquo_attr :age, no_query: true 202 | end 203 | ``` 204 | 205 | More accurate indications of the disk space requirements for the supported data types can be found in [the description of each data type](https://github.com/enquo/pg_enquo/tree/main/doc/data_types). 206 | 207 | 208 | # Future Developments 209 | 210 | ActiveEnquo is far from finished. 211 | Many more features are coming in the future. 212 | See [the Enquo project roadmap](https://enquo.org/roadmap) for details of what we're still intending to implement. 213 | 214 | 215 | # Contributing 216 | 217 | For general guidelines for contributions, see [CONTRIBUTING.md](CONTRIBUTING.md). 218 | Detailed information on developing `ActiveEnquo`, including how to run the test suite, can be found in [docs/DEVELOPMENT.md](DEVELOPMENT.md). 219 | 220 | 221 | # Licence 222 | 223 | Unless otherwise stated, everything in this repo is covered by the following 224 | licence statement: 225 | 226 | Copyright (C) 2022 Matt Palmer 227 | 228 | Permission is hereby granted, free of charge, to any person obtaining a copy 229 | of this software and associated documentation files (the "Software"), to deal 230 | in the Software without restriction, including without limitation the rights 231 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 232 | copies of the Software, and to permit persons to whom the Software is 233 | furnished to do so, subject to the following conditions: 234 | 235 | The above copyright notice and this permission notice shall be included in 236 | all copies or substantial portions of the Software. 237 | 238 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 239 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 240 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 241 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 242 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 243 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 244 | THE SOFTWARE. 245 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | exec(*(["bundle", "exec", $PROGRAM_NAME] + ARGV)) if ENV['BUNDLE_GEMFILE'].nil? 2 | 3 | task :default => :test 4 | 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | 13 | Bundler::GemHelper.install_tasks 14 | 15 | require 'yard' 16 | 17 | YARD::Rake::YardocTask.new :doc do |yardoc| 18 | yardoc.files = %w{lib/**/*.rb - README.md} 19 | end 20 | 21 | desc "Run guard" 22 | task :guard do 23 | sh "guard --clear" 24 | end 25 | 26 | require 'rspec/core/rake_task' 27 | RSpec::Core::RakeTask.new :test do |t| 28 | t.pattern = "spec/**/*_spec.rb" 29 | end 30 | -------------------------------------------------------------------------------- /active_enquo.gemspec: -------------------------------------------------------------------------------- 1 | begin 2 | require "git-version-bump" 3 | rescue LoadError 4 | nil 5 | end 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "active_enquo" 9 | 10 | s.version = GVB.version rescue "0.0.0.1.NOGVB" 11 | s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d") 12 | 13 | s.platform = Gem::Platform::RUBY 14 | 15 | s.summary = "ActiveRecord integration for encrypted querying operations" 16 | 17 | s.authors = ["Matt Palmer"] 18 | s.email = ["matt@enquo.org"] 19 | s.homepage = "https://enquo.org" 20 | 21 | s.metadata["homepage_uri"] = s.homepage 22 | s.metadata["source_code_uri"] = "https://github.com/enquo/active_enquo" 23 | s.metadata["changelog_uri"] = "https://github.com/enquo/active_enquo/releases" 24 | s.metadata["bug_tracker_uri"] = "https://github.com/enquo/active_enquo/issues" 25 | 26 | s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ } 27 | 28 | s.required_ruby_version = ">= 2.7.0" 29 | 30 | s.add_runtime_dependency "enquo-core", "~> 0.8" 31 | s.add_runtime_dependency "activerecord", ">= 6" 32 | 33 | s.add_development_dependency "bundler" 34 | s.add_development_dependency "github-release" 35 | s.add_development_dependency "guard-rspec" 36 | s.add_development_dependency "pg" 37 | s.add_development_dependency "rake", "~> 13.0" 38 | # Needed for guard 39 | s.add_development_dependency "rb-inotify", "~> 0.9" 40 | s.add_development_dependency "redcarpet" 41 | s.add_development_dependency "rspec" 42 | s.add_development_dependency "simplecov" 43 | s.add_development_dependency "yard" 44 | end 45 | -------------------------------------------------------------------------------- /docs/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | Here are some notes on how to do development on `ActiveEnquo`. 2 | 3 | # Setup 4 | 5 | 1. Checkout the repo. 6 | 7 | 2. Ensure you've got a Ruby 2.7+ installation as your default Ruby. 8 | 9 | 3. Run `bundle install`. 10 | 11 | ... and you're ready to go. 12 | 13 | 14 | # Running the test suite 15 | 16 | Run the test suite with `rake spec`. 17 | 18 | You'll need a Postgres database with the [`pg_enquo`](https://github.com/enquo/pg_enquo) extension installed. 19 | Use the [standard `PG*` environment variables](https://www.postgresql.org/docs/current/libpq-envars.html) to control the where the test suite connects to. 20 | If you're running a "test" `pg_enquo` database with `cargo pgx run pgNN`, then this should do the trick: 21 | 22 | ```sh 23 | $ PGHOST=localhost PGDATABASE=pg_enquo PGPORT=288NN rake spec 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/MIGRATION.md: -------------------------------------------------------------------------------- 1 | If you have data already in your database that you wish to protect using ActiveEnquo, you can migrate the data into an encrypted form. 2 | 3 | There are two approaches that can be used: 4 | 5 | * [Direct Migration](#data-migration-with-downtime), which is straightforward but involves application downtime; or 6 | 7 | * [Live Migration](#live-data-migration), which is more complicated, but can be done while keeping the application available at all times. 8 | 9 | 10 | # Data Migration (with Downtime) 11 | 12 | If your application can withstand being down for a period of time, you can use a simple migration process that encrypts the data and modifies the application appropriate in a single pass. 13 | It relies on their being a period with nothing accessing the column(s) being migrated, which usually means that the entire application (including background workers and periodic tasks) being shut down, before being restarted. 14 | The total downtime will depend on how long it takes to encrypt and write all the data being migrated, which is mostly a function of the amount of data being stored. 15 | 16 | 17 | ## Step 1: Configure the Encrypted Column(s) 18 | 19 | Create an ActiveRecord migration which renames the existing column and creates a new `enquo_*` column with the old name. 20 | For example, if you already had a `date_of_birth` column, your migration would look like this: 21 | 22 | ```ruby 23 | class EncryptUsersDateOfBirth < ActiveRecord::Migration[7.0] 24 | def change 25 | rename_column :users, :date_of_birth, :date_of_birth_plaintext 26 | add_column :users, :date_of_birth, :enquo_date 27 | User.enquo_encrypt_columns(date_of_birth_plaintext: :date_of_birth) 28 | remove_column :users, :date_of_birth_plaintext 29 | end 30 | end 31 | ``` 32 | 33 | The `Model.encrypt_columns` method loads all the records in the table, and encrypts the value in the plaintext column and writes it to the corresponding encrypted column. 34 | 35 | If you want to encrypt several columns in a single model, you can do so in a single migration, by renaming all the columns and adding `enquo_*` type columns, and then providing the mapping of all columns together in a single `Model.encrypt_columns` call. 36 | This is the recommended approach, as it improves efficiency because the records only have to be loaded and saved once. 37 | 38 | If you want to encrypt columns in multiple models in one downtime, just repeat the above steps for each table and model involved. 39 | 40 | 41 | ## Step 2: Modify Queries 42 | 43 | When providing data to a query on an encrypted column, you need to make a call to `Model.enquo` in order to encrypt the value for querying. 44 | 45 | To continue our `date_of_birth` example above, you need to find any queries that reference the `date_of_birth` column, and modify the code to pass the value for the `date_of_birth` column through a call to `User.enquo(:date_of_birth, )`. 46 | 47 | For a query that found all users with a date of birth equal to a query parameter, that originally looked like this: 48 | 49 | ```ruby 50 | User.where(date_of_birth: params[:dob]) 51 | ``` 52 | 53 | You'd modify it to look like this, instead: 54 | 55 | ```ruby 56 | User.where(date_of_birth: User.enquo(:date_of_birth, params[:dob])) 57 | ``` 58 | 59 | If the value for the query was passed in as a positional parameter, you just encrypt the value the same way, so that a query might look like this: 60 | 61 | ```ruby 62 | User.where("date_of_birth > ? OR date_of_birth IS NULL", User.enquo(:date_of_birth, params[:dob])) 63 | ``` 64 | 65 | 66 | ## Step 3: Deploy 67 | 68 | Once the above changes are all made and committed to revision control, it's time to commence the downtime. 69 | Shutdown all the application servers, background job workers, and anything else that accesses the database, then perform a normal deployment -- running the database migration process before starting the application again. 70 | 71 | The migration may take some time to run, if the table is large. 72 | 73 | 74 | ## Step 4: Enjoy Fully Encrypted Data 75 | 76 | The column(s) you migrated are now fully protected by Enquo's queryable encryption. 77 | Relax and enjoy your preferred beverage in celebration! 78 | 79 | 80 | # Live Data Migration 81 | 82 | Converting the data in a column to be fully encrypted, while avoiding any application downtime, requires making several changes to the application and database schema in alternating sequence. 83 | This is necessary to ensure that parts of the application stack running both older and newer code versions can work with the database schema in place at all times. 84 | 85 | > # WORK IN PROGRESS 86 | > 87 | > This section has not been written out in detail. 88 | > The short version is: 89 | > 90 | > 1. Rename the unencrypted column: 91 | > 1. `create_column :users, :date_of_birth_plaintext, :date` 92 | > 2. Modify the model to write changes to `date_of_birth` to `date_of_birth_plaintext` as well 93 | > 3. Deploy 94 | > 4. Migration to copy all `date_of_birth` values to `date_of_birth_plaintext` 95 | > 5. Deploy 96 | > 6. Modify app to read/query from `date_of_birth_plaintext`, add `date_of_birth` to `User.ignored_columns` 97 | > 7. Deploy 98 | > 8. `drop_column :users, :date_of_birth` 99 | > 2. Create the encrypted column: 100 | > 1. `create_column :users, :date_of_birth, :enquo_date` 101 | > 2. Modify the model to write changes to `date_of_birth_plaintext` to `date_of_birth` as well, remove `date_of_birth` from `User.ignored_columns` 102 | > 3. Deploy 103 | > 4. Migration to encrypt all `date_of_birth_plaintext` values into `date_of_birth` 104 | > 5. Deploy 105 | > 6. Modify app to read/encrypted query from `date_of_birth`, add `date_of_birth_plaintext` to `User.ignored_columns` 106 | > 7. Deploy 107 | > 8. `drop_column :users, :date_of_birth_plaintext` 108 | -------------------------------------------------------------------------------- /e2e_tests/.gitignore: -------------------------------------------------------------------------------- 1 | /.pgdbenv 2 | -------------------------------------------------------------------------------- /e2e_tests/001_direct_migration/exercise_model: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "active_record" 4 | require "active_enquo" 5 | require "pg" 6 | 7 | require_relative "../init" 8 | 9 | def assert_eq(expected, actual) 10 | unless expected == actual 11 | $stderr.puts "Expected #{expected.inspect} to equal #{actual.inspect}" 12 | exit 1 13 | end 14 | end 15 | 16 | def value(f, v) 17 | if ENV.key?("USING_ENQUO") 18 | People.enquo(f, v) 19 | else 20 | v 21 | end 22 | end 23 | 24 | ActiveRecord::Base.establish_connection(adapter: ENV.fetch("DBTYPE")) 25 | 26 | assert_eq(["Meyers"], People.where(first_name: value(:first_name, "Seth")).all.map { |p| p.last_name }) 27 | assert_eq(8, People.where(date_of_birth: value(:date_of_birth, "1980-01-01")..).count) 28 | -------------------------------------------------------------------------------- /e2e_tests/001_direct_migration/migrations/001_create_people_table.rb: -------------------------------------------------------------------------------- 1 | class CreatePeopleTable < ActiveRecord::Migration[ENV.fetch("AR_VERSION", "7.0").to_f] 2 | def change 3 | create_table :people do |t| 4 | t.string :first_name 5 | t.string :last_name 6 | t.date :date_of_birth 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /e2e_tests/001_direct_migration/migrations/002_encrypt_people_data.rb: -------------------------------------------------------------------------------- 1 | class EncryptPeopleData < ActiveRecord::Migration[ENV.fetch("AR_VERSION", "7.0").to_f] 2 | def up 3 | rename_column :people, :first_name, :first_name_plaintext 4 | rename_column :people, :last_name, :last_name_plaintext 5 | rename_column :people, :date_of_birth, :date_of_birth_plaintext 6 | 7 | add_column :people, :first_name, :enquo_text 8 | add_column :people, :last_name, :enquo_text 9 | add_column :people, :date_of_birth, :enquo_date 10 | 11 | People.enquo_encrypt_columns( 12 | { 13 | first_name_plaintext: :first_name, 14 | last_name_plaintext: :last_name, 15 | date_of_birth_plaintext: :date_of_birth, 16 | }, 17 | # Smol batch size exercises the batching functionality 18 | batch_size: 5 19 | ) 20 | 21 | remove_column :people, :first_name_plaintext 22 | remove_column :people, :last_name_plaintext 23 | remove_column :people, :date_of_birth_plaintext 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /e2e_tests/001_direct_migration/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | . ../helper.sh 6 | 7 | export DBTYPE="postgresql" 8 | 9 | check_enquo_pg_db 10 | clear_pg_db 11 | 12 | ar_db_migrate "001" 13 | load_people 14 | 15 | ./exercise_model 16 | 17 | ar_db_migrate "002" 18 | USING_ENQUO="y" ./exercise_model 19 | -------------------------------------------------------------------------------- /e2e_tests/helper.sh: -------------------------------------------------------------------------------- 1 | if [ -f "../.pgdbenv" ]; then 2 | . ../.pgdbenv 3 | fi 4 | 5 | check_enquo_pg_db() { 6 | if [ "$(psql -tAc "select count(extname) FROM pg_catalog.pg_extension WHERE extname='pg_enquo'")" != "1" ]; then 7 | echo "Specified PostgreSQL database does not have the pg_enquo extension." >&2 8 | echo "Check your PG* env vars for correctness, and install the extension if needed." >&2 9 | exit 1 10 | fi 11 | } 12 | 13 | clear_pg_db() { 14 | for tbl in $(psql -tAc "select relname from pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind='r' and n.nspname = 'public'"); do 15 | psql -c "DROP TABLE $tbl" >/dev/null 16 | done 17 | for seq in $(psql -tAc "select relname from pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n on n.oid = c.relnamespace where c.relkind='S' and n.nspname = 'public'"); do 18 | psql -c "DROP SEQUENCE $seq" >/dev/null 19 | done 20 | } 21 | 22 | run_ruby() { 23 | ruby -r ../init "$@" 24 | } 25 | 26 | ar_db_migrate() { 27 | local target_version="$1" 28 | 29 | run_ruby -e "ActiveRecord::MigrationContext.new(['migrations']).up($target_version)" >/dev/null 30 | } 31 | 32 | load_people() { 33 | run_ruby -e "People.create!(JSON.parse(File.read('../people.json')))" 34 | } 35 | -------------------------------------------------------------------------------- /e2e_tests/init.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "active_enquo" 3 | 4 | ActiveEnquo.root_key = Enquo::RootKey::Static.new("f91c5017a2d946403cc90a688266ff32d186aa2a00efd34dcaa86be802e179d0") 5 | 6 | DBTYPE = ENV.fetch("DBTYPE") 7 | 8 | case DBTYPE 9 | when "postgresql" 10 | require "pg" 11 | else 12 | raise "Unsupported DBTYPE: #{DBTYPE.inspect}" 13 | end 14 | 15 | class People < ActiveRecord::Base 16 | end 17 | 18 | ActiveRecord::Base.establish_connection(adapter: DBTYPE) 19 | -------------------------------------------------------------------------------- /e2e_tests/people.json: -------------------------------------------------------------------------------- 1 | [{"first_name":"Sergio","last_name":"Leone","date_of_birth":"1929-01-03"},{"first_name":"Bradley","last_name":"Cooper","date_of_birth":"1975-01-05"},{"first_name":"Robert","last_name":"Duvall","date_of_birth":"1931-01-05"},{"first_name":"Kate","last_name":"Moss","date_of_birth":"1974-01-16"},{"first_name":"Laura","last_name":"Schlessinger","date_of_birth":"1947-01-16"},{"first_name":"Jason","last_name":"Segel","date_of_birth":"1980-01-18"},{"first_name":"Pete","last_name":"Buttigieg","date_of_birth":"1982-01-19"},{"first_name":"Mischa","last_name":"Barton","date_of_birth":"1986-01-24"},{"first_name":"Shirley","last_name":"Mason","date_of_birth":"1923-01-25"},{"first_name":"Boris","last_name":"Yeltsin","date_of_birth":"1931-02-01"},{"first_name":"Tom","last_name":"Wilkinson","date_of_birth":"1948-02-05"},{"first_name":"Tony","last_name":"Iommi","date_of_birth":"1948-02-19"},{"first_name":"Sri","last_name":"Srinivasan","date_of_birth":"1967-02-23"},{"first_name":"Tony","last_name":"Randall","date_of_birth":"1920-02-26"},{"first_name":"David","last_name":"Blaine","date_of_birth":"1973-04-04"},{"first_name":"Muddy","last_name":"Waters","date_of_birth":"1915-04-04"},{"first_name":"Danny","last_name":"Almonte","date_of_birth":"1987-04-07"},{"first_name":"Meghann","last_name":"Shaughnessy","date_of_birth":"1979-04-13"},{"first_name":"Maisie","last_name":"Williams","date_of_birth":"1997-04-15"},{"first_name":"Immanuel","last_name":"Kant","date_of_birth":"1724-04-22"},{"first_name":"John","last_name":"Oliver","date_of_birth":"1977-04-23"},{"first_name":"Penelope","last_name":"Cruz","date_of_birth":"1974-04-28"},{"first_name":"Anne","last_name":"Parillaud","date_of_birth":"1961-05-06"},{"first_name":"Cate","last_name":"Blanchett","date_of_birth":"1969-05-14"},{"first_name":"Tucker","last_name":"Carlson","date_of_birth":"1969-05-16"},{"first_name":"Nancy","last_name":"Kwan","date_of_birth":"1939-05-19"},{"first_name":"Melissa","last_name":"Etheridge","date_of_birth":"1961-05-29"},{"first_name":"Marissa","last_name":"Mayer","date_of_birth":"1975-05-30"},{"first_name":"Marvin","last_name":"Hamlisch","date_of_birth":"1944-06-02"},{"first_name":"Gwendolyn","last_name":"Brooks","date_of_birth":"1917-06-07"},{"first_name":"Paul","last_name":"Lynde","date_of_birth":"1927-06-13"},{"first_name":"Paul","last_name":"McCartney","date_of_birth":"1942-06-18"},{"first_name":"Susan","last_name":"Hayward","date_of_birth":"1917-06-30"},{"first_name":"Léa","last_name":"Seydoux","date_of_birth":"1985-07-01"},{"first_name":"Amanda","last_name":"Knox","date_of_birth":"1987-07-09"},{"first_name":"Cindy","last_name":"Sheehan","date_of_birth":"1957-07-10"},{"first_name":"Bill","last_name":"Cosby","date_of_birth":"1937-07-12"},{"first_name":"Raymond","last_name":"Chandler","date_of_birth":"1888-07-23"},{"first_name":"Christopher","last_name":"Nolan","date_of_birth":"1970-07-30"},{"first_name":"Melanie","last_name":"Griffith","date_of_birth":"1957-08-09"},{"first_name":"Freddie","last_name":"Gray","date_of_birth":"1989-08-16"},{"first_name":"Steve","last_name":"Case","date_of_birth":"1958-08-21"},{"first_name":"Cal","last_name":"Ripken","date_of_birth":"1960-08-24"},{"first_name":"Regis","last_name":"Philbin","date_of_birth":"1931-08-25"},{"first_name":"Shania","last_name":"Twain","date_of_birth":"1965-08-28"},{"first_name":"Adam","last_name":"Curry","date_of_birth":"1964-09-03"},{"first_name":"Rachel","last_name":"Hunter","date_of_birth":"1969-09-09"},{"first_name":"Bashar","last_name":"al-Assad","date_of_birth":"1965-09-11"},{"first_name":"Amy","last_name":"Poehler","date_of_birth":"1971-09-16"},{"first_name":"Jim","last_name":"Thompson","date_of_birth":"1906-09-27"},{"first_name":"Matt","last_name":"Damon","date_of_birth":"1970-10-08"},{"first_name":"Leopold","last_name":"Senghor","date_of_birth":"1906-10-09"},{"first_name":"William","last_name":"Penn","date_of_birth":"1644-10-14"},{"first_name":"Rebecca","last_name":"Romijn","date_of_birth":"1971-11-06"},{"first_name":"Auguste","last_name":"Rodin","date_of_birth":"1840-11-12"},{"first_name":"Nate","last_name":"Parker","date_of_birth":"1979-11-18"},{"first_name":"Larry","last_name":"Bird","date_of_birth":"1956-12-07"},{"first_name":"Bobby","last_name":"Flay","date_of_birth":"1964-12-10"},{"first_name":"Chris","last_name":"Evert","date_of_birth":"1954-12-21"},{"first_name":"Frank","last_name":"Zappa","date_of_birth":"1940-12-21"},{"first_name":"Estella","last_name":"Warren","date_of_birth":"1978-12-23"},{"first_name":"Carlos","last_name":"Castaneda","date_of_birth":"1925-12-25"},{"first_name":"Seth","last_name":"Meyers","date_of_birth":"1973-12-28"}] -------------------------------------------------------------------------------- /e2e_tests/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | cd "$(dirname "${BASH_SOURCE[0]}")" 6 | 7 | for t in [0-9]*; do 8 | rv="0" 9 | cd "$t" 10 | 11 | ./run || rv="$?" 12 | 13 | if [ "$rv" != "0" ]; then 14 | echo "Test $i failed with exit code $rv" >&2 15 | exit 1 16 | fi 17 | done 18 | 19 | echo "All tests passed." 20 | -------------------------------------------------------------------------------- /lib/active_enquo.rb: -------------------------------------------------------------------------------- 1 | require "active_record/connection_adapters/postgresql_adapter" 2 | require "active_support/lazy_load_hooks" 3 | 4 | require "date" 5 | require "enquo" 6 | require "time" 7 | 8 | module ActiveEnquo 9 | def self.root_key=(k) 10 | @root = Enquo::Root.new(k) 11 | end 12 | 13 | def self.root 14 | if @root.nil? 15 | raise RuntimeError, "The ActiveEnquo root key must be set before calling ActiveEnquo.root" 16 | end 17 | 18 | @root 19 | end 20 | 21 | RootKey = Enquo::RootKey 22 | 23 | module ActiveRecord 24 | module QueryFilterMangler 25 | class Ciphertext < String 26 | end 27 | private_constant :Ciphertext 28 | 29 | private 30 | 31 | def mangle_query_filter(a) 32 | args = a.first 33 | if args.is_a?(Hash) 34 | args.keys.each do |attr| 35 | next unless enquo_attr?(attr) 36 | 37 | if args[attr].is_a?(Array) 38 | args[attr] = args[attr].map { |v| maybe_enquo(attr, v) } 39 | elsif args[attr].is_a?(Range) 40 | r = args[attr] 41 | args[attr] = if r.exclude_end? 42 | if r.begin.nil? 43 | ...maybe_enquo(attr, r.end) 44 | elsif r.end.nil? 45 | (maybe_enquo(attr.r.begin)...) 46 | else 47 | maybe_enquo(attr.r.begin)...maybe_enquo(attr, r.end) 48 | end 49 | else 50 | if r.begin.nil? 51 | ..maybe_enquo(attr, r.end) 52 | elsif r.end.nil? 53 | maybe_enquo(attr.r.begin).. 54 | else 55 | maybe_enquo(attr.r.begin)..maybe_enquo(attr, r.end) 56 | end 57 | end 58 | else 59 | args[attr] = maybe_enquo(attr, args[attr]) 60 | end 61 | end 62 | end 63 | end 64 | 65 | def maybe_enquo(attr, v) 66 | if v.nil? || v.is_a?(Ciphertext) || v.is_a?(::ActiveRecord::StatementCache::Substitute) 67 | v 68 | else 69 | Ciphertext.new(enquo(attr, v)) 70 | end 71 | end 72 | end 73 | 74 | module BaseExtension 75 | extend ActiveSupport::Concern 76 | 77 | def _read_attribute(attr_name, &block) 78 | t = self.class.attribute_types[attr_name] 79 | if t.is_a?(::ActiveEnquo::Type) 80 | relation = self.class.arel_table.name 81 | value = @attributes.fetch_value(attr_name, &block) 82 | return nil if value.nil? 83 | field = ::ActiveEnquo.root.field(relation, attr_name) 84 | begin 85 | t.decrypt(value, @attributes.fetch_value(@primary_key).to_s, field) 86 | rescue Enquo::Error 87 | # If the record had not yet been inserted into the database at the time the 88 | # attribute was originally written, then that attribute's context will be empty. 89 | # This is troublesome, but it's tricky to solve at this layer, so we'll have to 90 | # take the risk and try and decryption with empty context. 91 | t.decrypt(value, "", field) 92 | end 93 | else 94 | super 95 | end 96 | end 97 | 98 | def _write_attribute(attr_name, value) 99 | t = self.class.attribute_types[attr_name] 100 | if t.is_a?(::ActiveEnquo::Type) 101 | relation = self.class.arel_table.name 102 | field = ::ActiveEnquo.root.field(relation, attr_name) 103 | attr_opts = self.class.enquo_attribute_options.fetch(attr_name.to_sym, {}) 104 | db_value = t.encrypt(value, @attributes.fetch_value(@primary_key).to_s, field, **attr_opts) 105 | @attributes.write_from_user(attr_name, db_value) 106 | else 107 | super 108 | end 109 | end 110 | 111 | module ClassMethods 112 | include QueryFilterMangler 113 | 114 | def find_by(*a) 115 | mangle_query_filter(a) 116 | super 117 | end 118 | 119 | def enquo(attr_name, value_or_meta_id, maybe_value = nil) 120 | meta_id, value = if value_or_meta_id.is_a?(Symbol) 121 | [value_or_meta_id, maybe_value] 122 | else 123 | [nil, value_or_meta_id] 124 | end 125 | 126 | t = self.attribute_types[attr_name.to_s] 127 | if t.is_a?(::ActiveEnquo::Type) 128 | relation = self.arel_table.name.to_s 129 | field = ::ActiveEnquo.root.field(relation, attr_name.to_s) 130 | if meta_id.nil? 131 | t.encrypt(value, "", field, enable_reduced_security_operations: true) 132 | else 133 | t.encrypt_metadata_value(meta_id, value, field) 134 | end 135 | else 136 | raise ArgumentError, "Cannot produce encrypted value on a non-enquo attribute '#{attr_name}'" 137 | end 138 | end 139 | 140 | def unenquo(attr_name, value, ctx) 141 | t = self.attribute_types[attr_name.to_s] 142 | if t.is_a?(::ActiveEnquo::Type) 143 | relation = self.arel_table.name.to_s 144 | field = ::ActiveEnquo.root.field(relation, attr_name.to_s) 145 | begin 146 | t.decrypt(value, ctx, field) 147 | rescue Enquo::Error 148 | t.decrypt(value, "", field) 149 | end 150 | else 151 | raise ArgumentError, "Cannot decrypt value on a non-enquo attribute '#{attr_name}'" 152 | end 153 | end 154 | 155 | def enquo_attr?(attr_name) 156 | self.attribute_types[attr_name.to_s].is_a?(::ActiveEnquo::Type) 157 | end 158 | 159 | def enquo_attr(attr_name, opts) 160 | if opts.key?(:default) 161 | default_value = opts.delete(:default) 162 | after_initialize do 163 | next if persisted? 164 | next unless self.send(attr_name).nil? 165 | self.send(:"#{attr_name}=", default_value.duplicable? ? default_value.dup : default_value) 166 | end 167 | end 168 | 169 | enquo_attribute_options[attr_name] = @enquo_attribute_options[attr_name].merge(opts) 170 | end 171 | 172 | def enquo_attribute_options 173 | @enquo_attribute_options ||= Hash.new({}) 174 | end 175 | 176 | def enquo_encrypt_columns(column_map, batch_size: 10_000) 177 | plaintext_columns = column_map.keys 178 | relation = self.arel_table.name 179 | in_progress = true 180 | self.reset_column_information 181 | 182 | while in_progress 183 | self.transaction do 184 | # The .where("0=1") here is a dummy condition so that the q.or in the .each will work properly 185 | q = self.select(self.primary_key).select(plaintext_columns).where("0=1") 186 | column_map.each do |pt_col, ct_col| 187 | q = q.or(self.where(ct_col => nil).where.not(pt_col => nil)) 188 | end 189 | 190 | q = q.limit(batch_size).lock 191 | 192 | rows = ::ActiveRecord::Base.connection.exec_query(q.to_sql) 193 | if rows.length == 0 194 | in_progress = false 195 | else 196 | rows.each do |row| 197 | values = Hash[column_map.map do |pt_col, ct_col| 198 | field = ::ActiveEnquo.root.field(relation, ct_col) 199 | attr_opts = self.enquo_attribute_options.fetch(ct_col.to_sym, {}) 200 | t = self.attribute_types[ct_col.to_s] 201 | db_value = t.encrypt(row[pt_col.to_s], row[self.primary_key].to_s, field, **attr_opts) 202 | 203 | [ct_col, db_value] 204 | end] 205 | 206 | self.where(self.primary_key => row[self.primary_key]).update_all(values) 207 | end 208 | end 209 | end 210 | end 211 | end 212 | end 213 | end 214 | 215 | module TableDefinitionExtension 216 | def enquo_boolean(name, **options) 217 | column(name, :enquo_boolean, **options) 218 | end 219 | 220 | def enquo_bigint(name, **options) 221 | column(name, :enquo_bigint, **options) 222 | end 223 | 224 | def enquo_date(name, **options) 225 | column(name, :enquo_date, **options) 226 | end 227 | 228 | def enquo_text(name, **options) 229 | column(name, :enquo_text, **options) 230 | end 231 | end 232 | 233 | module RelationExtension 234 | include QueryFilterMangler 235 | extend ActiveSupport::Concern 236 | 237 | def where(*a) 238 | mangle_query_filter(a) 239 | super 240 | end 241 | 242 | def exists?(*a) 243 | mangle_query_filter(a) 244 | super 245 | end 246 | 247 | end 248 | end 249 | 250 | module Postgres 251 | module ConnectionAdapter 252 | def initialize_type_map(m = type_map) 253 | m.register_type "enquo_boolean", ActiveEnquo::Type::Boolean.new 254 | m.register_type "enquo_bigint", ActiveEnquo::Type::Bigint.new 255 | m.register_type "enquo_date", ActiveEnquo::Type::Date.new 256 | m.register_type "enquo_text", ActiveEnquo::Type::Text.new 257 | 258 | super 259 | end 260 | end 261 | end 262 | 263 | class Type < ::ActiveRecord::Type::Value 264 | class Boolean < Type 265 | def type 266 | :enquo_boolean 267 | end 268 | 269 | def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false) 270 | if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute) 271 | value 272 | else 273 | field.encrypt_boolean(value, context, unsafe: enable_reduced_security_operations, no_query: no_query) 274 | end 275 | end 276 | 277 | def decrypt(value, context, field) 278 | field.decrypt_boolean(value, context) 279 | end 280 | end 281 | 282 | class Bigint < Type 283 | def type 284 | :enquo_bigint 285 | end 286 | 287 | def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false) 288 | if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute) 289 | value 290 | else 291 | field.encrypt_i64(value, context, unsafe: enable_reduced_security_operations, no_query: no_query) 292 | end 293 | end 294 | 295 | def decrypt(value, context, field) 296 | field.decrypt_i64(value, context) 297 | end 298 | end 299 | 300 | class Date < Type 301 | def type 302 | :enquo_date 303 | end 304 | 305 | def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false) 306 | value = cast_to_date(value) 307 | if value.nil? 308 | value 309 | else 310 | field.encrypt_date(value, context, unsafe: enable_reduced_security_operations, no_query: no_query) 311 | end 312 | end 313 | 314 | def decrypt(value, context, field) 315 | field.decrypt_date(value, context) 316 | end 317 | 318 | private 319 | 320 | def cast_to_date(value) 321 | if Date === value 322 | value 323 | elsif value.respond_to?(:to_date) 324 | value.to_date 325 | elsif value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.is_a?(::ActiveRecord::StatementCache::Substitute) 326 | nil 327 | else 328 | Time.parse(value.to_s).to_date 329 | end 330 | end 331 | end 332 | 333 | class Text < Type 334 | def type 335 | :enquo_text 336 | end 337 | 338 | def encrypt(value, context, field, enable_reduced_security_operations: false, no_query: false, enable_ordering: false) 339 | if enable_ordering && !enable_reduced_security_operations 340 | raise ArgumentError, "Cannot enable ordering on an Enquo attribute unless Reduced Security Operations are enabled" 341 | end 342 | 343 | if value.nil? || value.is_a?(::ActiveRecord::StatementCache::Substitute) 344 | value 345 | else 346 | opts = { 347 | unsafe: enable_reduced_security_operations, 348 | no_query: no_query, 349 | } 350 | if enable_ordering 351 | opts[:order_prefix_length] = 8 352 | end 353 | 354 | field.encrypt_text(value.respond_to?(:encode) ? value.encode("UTF-8") : value, context, **opts) 355 | end 356 | end 357 | 358 | def encrypt_metadata_value(name, value, field) 359 | case name 360 | when :length 361 | field.encrypt_text_length_query(value) 362 | else 363 | raise ArgumentError, "Unknown metadata name for Text field: #{name.inspect}" 364 | end 365 | end 366 | 367 | def decrypt(value, context, field) 368 | if value.nil? 369 | nil 370 | else 371 | field.decrypt_text(value, context) 372 | end 373 | end 374 | end 375 | end 376 | 377 | if defined?(Rails::Railtie) 378 | class Initializer < Rails::Railtie 379 | initializer "active_enquo.root_key" do |app| 380 | if app 381 | if app.credentials 382 | if app.credentials.active_enquo 383 | if root_key = app.credentials.active_enquo.root_key 384 | ActiveEnquo.root_key = Enquo::RootKey::Static.new(root_key) 385 | else 386 | Rails.logger.warn "Could not initialize ActiveEnquo, as no active_enquo.root_key credential was found for this environment" 387 | end 388 | else 389 | Rails.logger.warn "Could not initialize ActiveEnquo, as no active_enquo credentials were found for this environment" 390 | end 391 | else 392 | Rails.logger.warn "Could not initialize ActiveEnquo, as no credentials were found for this environment" 393 | end 394 | else 395 | Rails.logger.warn "Could not initialize ActiveEnquo, as no app was found for this environment" 396 | end 397 | end 398 | end 399 | end 400 | end 401 | 402 | ActiveSupport.on_load(:active_record) do 403 | ::ActiveRecord::Relation.prepend ActiveEnquo::ActiveRecord::RelationExtension 404 | ::ActiveRecord::Base.include ActiveEnquo::ActiveRecord::BaseExtension 405 | 406 | ::ActiveRecord::ConnectionAdapters::Table.include ActiveEnquo::ActiveRecord::TableDefinitionExtension 407 | ::ActiveRecord::ConnectionAdapters::TableDefinition.include ActiveEnquo::ActiveRecord::TableDefinitionExtension 408 | 409 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend ActiveEnquo::Postgres::ConnectionAdapter 410 | 411 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_boolean] = { name: "enquo_boolean" } 412 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_bigint] = { name: "enquo_bigint" } 413 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_date] = { name: "enquo_date" } 414 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enquo_text] = { name: "enquo_text" } 415 | end 416 | -------------------------------------------------------------------------------- /spec/bigint/insertion_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/bigint" 5 | 6 | def bind_param(value) 7 | ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) 8 | end 9 | 10 | shared_examples "an ORE-encrypted bigint" do |model, value, unsafe: false, no_query: false| 11 | let(:record) { model.new(value: value) } 12 | let(:db_value) do 13 | record.save! 14 | model.connection.select_one("SELECT value FROM #{model.arel_table.name} WHERE id=$1", nil, [bind_param(record.id)])["value"] 15 | end 16 | 17 | it "succeeds" do 18 | expect { record.save! }.to_not raise_error 19 | end 20 | 21 | it "stores the value as a JSON document" do 22 | expect { JSON.parse(db_value) }.to_not raise_error 23 | end 24 | 25 | let(:json_value) { JSON.parse(db_value, symbolize_names: true) } 26 | 27 | it "is a v1 ciphertext" do 28 | expect(json_value).to have_key(:v1) 29 | end 30 | 31 | it "has an AES v1 ciphertext" do 32 | expect(json_value[:v1]).to have_key(:a) 33 | end 34 | 35 | unless no_query 36 | it "has an ORE v1 ciphertext" do 37 | expect(json_value[:v1]).to have_key(:o) 38 | end 39 | end 40 | 41 | it "has a key ID" do 42 | expect(json_value[:v1]).to have_key(:k) 43 | expect(json_value[:v1][:k]).to match([a_value_between(0, 255)] * 4) 44 | end 45 | 46 | let(:aes) { json_value[:v1][:a] } 47 | 48 | it "has a 96 bit IV" do 49 | expect(aes[:iv]).to match([a_value_between(0, 255)] * 12) 50 | end 51 | 52 | it "has a bytestring ciphertext" do 53 | expect(aes[:ct].length).to satisfy { |l| l >= 8 } 54 | expect(aes[:ct]).to all be_between(0, 255) 55 | end 56 | 57 | let(:ore) { json_value[:v1][:o] } 58 | 59 | if no_query 60 | it "has no ORE ciphertext" do 61 | expect(ore).to be_nil 62 | end 63 | else 64 | if unsafe 65 | it "has a bytestring left ORE ciphertext" do 66 | expect(ore[:l].length).to eq(136) 67 | expect(ore[:l]).to all be_between(0, 255) 68 | end 69 | else 70 | it "has no left ORE ciphertext" do 71 | expect(ore[:l]).to be_nil 72 | end 73 | end 74 | 75 | it "has a bytestring right ORE ciphertext" do 76 | expect(ore[:r].length).to be_between(420, 460) 77 | expect(ore[:r]).to all be_between(0, 255) 78 | end 79 | end 80 | end 81 | 82 | describe "record insertion" do 83 | context "into bigint" do 84 | { 85 | "zero" => 0, 86 | "a small positive integer" => 42, 87 | "a small negative integer" => -42, 88 | "a large positive integer" => 2**42, 89 | "a large negative integer" => -2**42, 90 | }.each do |desc, v| 91 | context "storing #{desc}" do 92 | it_behaves_like "an ORE-encrypted bigint", Bigint, v 93 | end 94 | end 95 | 96 | { 97 | "a slightly too large positive integer" => 2**63, 98 | "an excessively large positive integer" => 2**420, 99 | "a slightly too large (small?) negative integer" => -2**63 - 1, 100 | "an excessively large negative integer" => -2**420, 101 | }.each do |desc, v| 102 | context "storing #{desc}" do 103 | let(:model) { Bigint } 104 | let(:value) { v } 105 | 106 | it "explodes" do 107 | expect { Bigint.new(value: v).save! }.to raise_error(RangeError) 108 | end 109 | end 110 | end 111 | 112 | { 113 | "a float" => 4.2, 114 | "a string" => "ohai!", 115 | }.each do |desc, v| 116 | context "storing #{desc}" do 117 | let(:model) { Bigint } 118 | let(:value) { v } 119 | 120 | it "explodes" do 121 | expect { Bigint.new(value: v).save! }.to raise_error(TypeError) 122 | end 123 | end 124 | end 125 | end 126 | 127 | context "into sortable_bigint" do 128 | { 129 | "zero" => 0, 130 | "a small positive integer" => 42, 131 | "a small negative integer" => -42, 132 | "a large positive integer" => 2**42, 133 | "a large negative integer" => -2**42, 134 | }.each do |desc, v| 135 | context "storing #{desc}" do 136 | it_behaves_like "an ORE-encrypted bigint", SortableBigint, v, unsafe: true 137 | end 138 | end 139 | end 140 | 141 | context "into unqueryable_bigint" do 142 | { 143 | "zero" => 0, 144 | "a small positive integer" => 42, 145 | "a small negative integer" => -42, 146 | "a large positive integer" => 2**42, 147 | "a large negative integer" => -2**42, 148 | }.each do |desc, v| 149 | context "storing #{desc}" do 150 | it_behaves_like "an ORE-encrypted bigint", UnqueryableBigint, v, no_query: true 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /spec/bigint/retrieval_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/bigint" 5 | 6 | describe "record retrieval" do 7 | [Bigint, SortableBigint, UnqueryableBigint].each do |model| 8 | context "from #{model}" do 9 | before(:all) do 10 | model.create!([ 11 | { value: 0, notes: "zero" }, 12 | { value: 42, notes: "small positive" }, 13 | { value: 420, notes: "medium positive" }, 14 | { value: 2**42, notes: "large positive" }, 15 | { value: -42, notes: "small negative" }, 16 | { value: -420, notes: "medium negative" }, 17 | { value: -2**42, notes: "large negative" }, 18 | ]) 19 | end 20 | 21 | it "decrypts a single record" do 22 | expect(model.where(notes: "zero").first.value).to eq(0) 23 | expect(model.where(notes: "medium positive").first.value).to eq(420) 24 | expect(model.where(notes: "large negative").first.value).to eq(-2**42) 25 | end 26 | 27 | if model == UnqueryableBigint 28 | it "cannot be queried" do 29 | expect { model.where(value: 0).first }.to raise_error(ActiveRecord::StatementInvalid) 30 | end 31 | else 32 | it "retrieves and decrypts a single record" do 33 | [0, 42, 420, 2**42, -42, -420, -2**42].each do |i| 34 | expect(model.where(value: i).first.value).to eq(i) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/boolean/insertion_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/boolean" 5 | 6 | def bind_param(value) 7 | ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) 8 | end 9 | 10 | shared_examples "an ORE-encrypted boolean" do |model, value, unsafe: false, no_query: false| 11 | let(:record) { model.new(value: value) } 12 | let(:db_value) do 13 | record.save! 14 | model.connection.select_one("SELECT value FROM #{model.arel_table.name} WHERE id=$1", nil, [bind_param(record.id)])["value"] 15 | end 16 | 17 | it "succeeds" do 18 | expect { record.save! }.to_not raise_error 19 | end 20 | 21 | it "stores the value as a JSON document" do 22 | expect { JSON.parse(db_value) }.to_not raise_error 23 | end 24 | 25 | let(:json_value) { JSON.parse(db_value, symbolize_names: true) } 26 | 27 | it "is a v1 ciphertext" do 28 | expect(json_value).to have_key(:v1) 29 | end 30 | 31 | it "has an AES v1 ciphertext" do 32 | expect(json_value[:v1]).to have_key(:a) 33 | end 34 | 35 | unless no_query 36 | it "has an ORE v1 ciphertext" do 37 | expect(json_value[:v1]).to have_key(:o) 38 | end 39 | end 40 | 41 | it "has a key ID" do 42 | expect(json_value[:v1]).to have_key(:k) 43 | expect(json_value[:v1][:k]).to match([a_value_between(0, 255)] * 4) 44 | end 45 | 46 | let(:aes) { json_value[:v1][:a] } 47 | 48 | it "has a 96 bit IV" do 49 | expect(aes[:iv]).to match([a_value_between(0, 255)] * 12) 50 | end 51 | 52 | it "has a bytestring ciphertext" do 53 | expect(aes[:ct].length).to satisfy { |l| l >= 8 } 54 | expect(aes[:ct]).to all be_between(0, 255) 55 | end 56 | 57 | let(:ore) { json_value[:v1][:o] } 58 | 59 | if no_query 60 | it "has no ORE ciphertext" do 61 | expect(ore).to be_nil 62 | end 63 | else 64 | if unsafe 65 | it "has a bytestring left ORE ciphertext" do 66 | expect(ore[:l].length).to eq(17) 67 | expect(ore[:l]).to all be_between(0, 255) 68 | end 69 | else 70 | it "has no left ORE ciphertext" do 71 | expect(ore[:l]).to be_nil 72 | end 73 | end 74 | 75 | it "has a bytestring right ORE ciphertext" do 76 | expect(ore[:r].length).to be_between(16, 19) 77 | expect(ore[:r]).to all be_between(0, 255) 78 | end 79 | end 80 | end 81 | 82 | describe "record insertion" do 83 | context "into boolean" do 84 | { 85 | "true" => true, 86 | "false" => false, 87 | }.each do |desc, v| 88 | context "storing #{desc}" do 89 | it_behaves_like "an ORE-encrypted boolean", Boolean, v 90 | end 91 | end 92 | 93 | { 94 | "an integer" => 42, 95 | "a float" => 4.2, 96 | "a string" => "ohai!", 97 | }.each do |desc, v| 98 | context "storing #{desc}" do 99 | let(:model) { Boolean } 100 | let(:value) { v } 101 | 102 | it "explodes" do 103 | expect { Boolean.new(value: v).save! }.to raise_error(TypeError) 104 | end 105 | end 106 | end 107 | end 108 | 109 | context "into sortable_boolean" do 110 | { 111 | "true" => true, 112 | "false" => false, 113 | }.each do |desc, v| 114 | context "storing #{desc}" do 115 | it_behaves_like "an ORE-encrypted boolean", SortableBoolean, v, unsafe: true 116 | end 117 | end 118 | end 119 | 120 | context "into unqueryable_boolean" do 121 | { 122 | "true" => true, 123 | "false" => false, 124 | }.each do |desc, v| 125 | context "storing #{desc}" do 126 | it_behaves_like "an ORE-encrypted boolean", UnqueryableBoolean, v, no_query: true 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /spec/boolean/retrieval_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/boolean" 5 | 6 | describe "record retrieval" do 7 | [Boolean, SortableBoolean, UnqueryableBoolean].each do |model| 8 | context "from #{model}" do 9 | before(:all) do 10 | model.create!([ 11 | { value: true, notes: "true" }, 12 | { value: false, notes: "false" }, 13 | ]) 14 | end 15 | 16 | it "decrypts a single record" do 17 | expect(model.where(notes: "true").first.value).to eq(true) 18 | expect(model.where(notes: "false").first.value).to eq(false) 19 | end 20 | 21 | if model == UnqueryableBoolean 22 | it "cannot be queried" do 23 | expect { model.where(value: false).first }.to raise_error(ActiveRecord::StatementInvalid) 24 | end 25 | else 26 | it "retrieves and decrypts a single record" do 27 | [true, false].each do |i| 28 | expect(model.where(value: i).first.value).to eq(i) 29 | end 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/date/insertion_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | require "date" 4 | 5 | require_relative "../support/models/date" 6 | 7 | def bind_param(value) 8 | ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) 9 | end 10 | 11 | shared_examples "an ORE-encrypted date" do |model, value, unsafe: false, no_query: false| 12 | let(:record) { model.new(value: value) } 13 | let(:db_value) do 14 | record.save! 15 | model.connection.select_one("SELECT value FROM #{model.arel_table.name} WHERE id=$1", nil, [bind_param(record.id)])["value"] 16 | end 17 | 18 | it "succeeds" do 19 | expect { record.save! }.to_not raise_error 20 | end 21 | 22 | it "stores the value as a JSON document" do 23 | expect { JSON.parse(db_value) }.to_not raise_error 24 | end 25 | 26 | let(:json_value) { JSON.parse(db_value, symbolize_names: true) } 27 | 28 | it "is a v1 ciphertext" do 29 | expect(json_value).to have_key(:v1) 30 | end 31 | 32 | it "has an AES v1 ciphertext" do 33 | expect(json_value[:v1]).to have_key(:a) 34 | end 35 | 36 | it "has a key ID" do 37 | expect(json_value[:v1]).to have_key(:k) 38 | expect(json_value[:v1][:k]).to match([a_value_between(0, 255)] * 4) 39 | end 40 | 41 | let(:aes) { json_value[:v1][:a] } 42 | 43 | it "has a 96 bit IV" do 44 | expect(aes[:iv]).to match([a_value_between(0, 255)] * 12) 45 | end 46 | 47 | it "has a bytestring ciphertext" do 48 | expect(aes[:ct].length).to satisfy { |l| l >= 8 } 49 | expect(aes[:ct]).to all be_between(0, 255) 50 | end 51 | 52 | let(:ore_year) { json_value[:v1][:y] } 53 | let(:ore_month) { json_value[:v1][:m] } 54 | let(:ore_day) { json_value[:v1][:d] } 55 | 56 | if no_query 57 | it "has no ORE ciphertexts" do 58 | expect(ore_year).to be_nil 59 | expect(ore_month).to be_nil 60 | expect(ore_day).to be_nil 61 | end 62 | else 63 | if unsafe 64 | it "has bytestring left ORE ciphertexts" do 65 | expect(ore_year[:l].length).to eq(34) 66 | expect(ore_year[:l]).to all be_between(0, 255) 67 | expect(ore_month[:l].length).to eq(17) 68 | expect(ore_month[:l]).to all be_between(0, 255) 69 | expect(ore_day[:l].length).to eq(17) 70 | expect(ore_day[:l]).to all be_between(0, 255) 71 | end 72 | else 73 | it "has no left ORE ciphertexts" do 74 | expect(ore_year[:l]).to be_nil 75 | expect(ore_month[:l]).to be_nil 76 | expect(ore_day[:l]).to be_nil 77 | end 78 | end 79 | 80 | it "has bytestring right ORE ciphertexts" do 81 | expect(ore_year[:r].length).to be_between(110, 140) 82 | expect(ore_year[:r]).to all be_between(0, 255) 83 | expect(ore_month[:r].length).to be_between(20, 30) 84 | expect(ore_month[:r]).to all be_between(0, 255) 85 | expect(ore_day[:r].length).to be_between(20, 30) 86 | expect(ore_day[:r]).to all be_between(0, 255) 87 | end 88 | end 89 | end 90 | 91 | describe "record insertion" do 92 | context "into date" do 93 | { 94 | "around now" => Date.new(2022, 9, 1), 95 | "a little while ago" => Date.new(1970, 1, 1), 96 | "a long time ago" => Date.new(1492, 12, 17), 97 | "a *really* long time ago" => Date.new(-4000, 1, 1), 98 | "not long enough in the future" => Date.new(2038, 1, 19), 99 | "a long time in the future" => Date.new(2106, 2, 7), 100 | "a *really* long time in the future" => Date.new(4096, 1, 1), 101 | }.each do |desc, v| 102 | context "storing #{desc}" do 103 | it_behaves_like "an ORE-encrypted date", EnDate, v 104 | end 105 | end 106 | 107 | { 108 | "too long ago" => Date.new(-33_000, 1, 1), 109 | "too far in the future" => Date.new(33_000, 1, 1), 110 | }.each do |desc, v| 111 | context "storing #{desc}" do 112 | let(:model) { EnDate } 113 | let(:value) { v } 114 | 115 | it "explodes" do 116 | expect { EnDate.new(value: v).save! }.to raise_error(RangeError) 117 | end 118 | end 119 | end 120 | 121 | { 122 | "a float" => 4.2, 123 | "a string" => "ohai!", 124 | }.each do |desc, v| 125 | context "storing #{desc}" do 126 | let(:model) { EnDate } 127 | let(:value) { v } 128 | 129 | it "explodes" do 130 | expect { EnDate.new(value: v).save! }.to raise_error(ArgumentError) 131 | end 132 | end 133 | end 134 | end 135 | 136 | context "into sortable_date" do 137 | { 138 | "around now" => Date.new(2022, 9, 1), 139 | "a little while ago" => Date.new(1970, 1, 1), 140 | "a long time ago" => Date.new(1492, 12, 17), 141 | "a *really* long time ago" => Date.new(-4000, 1, 1), 142 | "not long enough in the future" => Date.new(2038, 1, 19), 143 | "a long time in the future" => Date.new(2106, 2, 7), 144 | "a *really* long time in the future" => Date.new(4096, 1, 1), 145 | }.each do |desc, v| 146 | context "storing #{desc}" do 147 | it_behaves_like "an ORE-encrypted date", SortableDate, v, unsafe: true 148 | end 149 | end 150 | end 151 | 152 | context "into unqueryable_date" do 153 | { 154 | "around now" => Date.new(2022, 9, 1), 155 | "a little while ago" => Date.new(1970, 1, 1), 156 | "a long time ago" => Date.new(1492, 12, 17), 157 | "a *really* long time ago" => Date.new(-4000, 1, 1), 158 | "not long enough in the future" => Date.new(2038, 1, 19), 159 | "a long time in the future" => Date.new(2106, 2, 7), 160 | "a *really* long time in the future" => Date.new(4096, 1, 1), 161 | }.each do |desc, v| 162 | context "storing #{desc}" do 163 | it_behaves_like "an ORE-encrypted date", UnqueryableDate, v, no_query: true 164 | end 165 | end 166 | end 167 | end 168 | 169 | -------------------------------------------------------------------------------- /spec/date/retrieval_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/date" 5 | 6 | describe "record retrieval" do 7 | [EnDate, SortableDate, UnqueryableDate].each do |model| 8 | context "from #{model}" do 9 | before(:all) do 10 | model.delete_all 11 | model.create!([ 12 | { notes: "around now", value: Date.new(2022, 9, 1) }, 13 | { notes: "a little while ago", value: Date.new(1970, 1, 1) }, 14 | { notes: "a long time ago", value: Date.new(1492, 12, 17) }, 15 | { notes: "a *really* long time ago", value: Date.new(-4000, 1, 1) }, 16 | { notes: "not long enough in the future", value: Date.new(2038, 1, 19) }, 17 | { notes: "a long time in the future", value: Date.new(2106, 2, 7) }, 18 | { notes: "a *really* long time in the future", value: Date.new(4096, 1, 1) }, 19 | ]) 20 | end 21 | 22 | it "decrypts a single record" do 23 | expect(model.where(notes: "around now").first.value).to eq(Date.new(2022, 9, 1)) 24 | expect(model.where(notes: "a *really* long time ago").first.value).to eq(Date.new(-4000, 1, 1)) 25 | expect(model.where(notes: "a long time in the future").first.value).to eq(Date.new(2106, 2, 7)) 26 | end 27 | 28 | if model == UnqueryableDate 29 | it "cannot be queried" do 30 | expect { model.where(value: Date.new(1970, 1, 1)).first }.to raise_error(ActiveRecord::StatementInvalid) 31 | end 32 | else 33 | it "retrieves and decrypts a single record" do 34 | [ 35 | Date.new(2022, 9, 1), 36 | Date.new(1970, 1, 1), 37 | Date.new(1492, 12, 17), 38 | Date.new(-4000, 1, 1), 39 | Date.new(2038, 1, 19), 40 | Date.new(2106, 2, 7), 41 | Date.new(4096, 1, 1), 42 | ].each do |i| 43 | expect(model.where(value: i).first.value).to eq(i) 44 | end 45 | end 46 | 47 | it "queries correctly" do 48 | expect(model.where(value: ...Date.new(2000, 1, 1)).count).to eq(3) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler.setup(:default, :development) 3 | require 'rspec/core' 4 | require 'rspec/mocks' 5 | 6 | require "active_record" 7 | require_relative "./support/migrations/create_bigints" 8 | require_relative "./support/migrations/create_booleans" 9 | require_relative "./support/migrations/create_dates" 10 | require_relative "./support/migrations/create_texts" 11 | 12 | require 'simplecov' 13 | SimpleCov.start do 14 | enable_coverage :branch 15 | primary_coverage :branch 16 | add_filter('spec') 17 | end 18 | 19 | class ListIncompletelyCoveredFiles 20 | def format(result) 21 | incompletes = result.files.select { |f| f.covered_percent < 100 } 22 | 23 | unless incompletes.empty? 24 | puts 25 | puts "Files with incomplete test coverage:" 26 | incompletes.each do |f| 27 | printf " %2.01f%% %s\n", f.covered_percent, f.filename 28 | end 29 | puts; puts 30 | end 31 | end 32 | end 33 | 34 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new([ 35 | SimpleCov::Formatter::HTMLFormatter, 36 | ListIncompletelyCoveredFiles 37 | ]) 38 | 39 | RSpec.configure do |config| 40 | config.order = :random 41 | config.fail_fast = !!ENV["RSPEC_CONFIG_FAIL_FAST"] 42 | config.full_backtrace = !!ENV["RSPEC_CONFIG_FULL_BACKTRACE"] 43 | 44 | config.expect_with :rspec do |c| 45 | c.syntax = :expect 46 | end 47 | 48 | config.before(:suite) do 49 | ActiveRecord::Base.establish_connection(adapter: 'postgresql') 50 | 51 | [ 52 | CreateBigints, CreateSortableBigints, CreateUnqueryableBigints, 53 | CreateBooleans, CreateSortableBooleans, CreateUnqueryableBooleans, 54 | CreateEnDates, CreateSortableDates, CreateUnqueryableDates, 55 | CreateTexts, CreateUnqueryableTexts, 56 | ].each do |migration| 57 | migration.migrate(:down) rescue nil 58 | migration.migrate(:up) 59 | end 60 | 61 | ::ActiveEnquo.root_key = ::ActiveEnquo::RootKey::Static.new(SecureRandom.bytes(32)) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/support/migrations/create_bigints.rb: -------------------------------------------------------------------------------- 1 | class CreateBigints < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :bigints do |t| 4 | t.column :value, :enquo_bigint 5 | t.string :notes 6 | end 7 | end 8 | end 9 | 10 | class CreateSortableBigints < ActiveRecord::Migration[6.0] 11 | def change 12 | create_table :sortable_bigints do |t| 13 | t.column :value, :enquo_bigint 14 | t.string :notes 15 | end 16 | end 17 | end 18 | 19 | class CreateUnqueryableBigints < ActiveRecord::Migration[6.0] 20 | def change 21 | create_table :unqueryable_bigints do |t| 22 | t.column :value, :enquo_bigint 23 | t.string :notes 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/migrations/create_booleans.rb: -------------------------------------------------------------------------------- 1 | class CreateBooleans < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :booleans do |t| 4 | t.column :value, :enquo_boolean 5 | t.string :notes 6 | end 7 | end 8 | end 9 | 10 | class CreateSortableBooleans < ActiveRecord::Migration[6.0] 11 | def change 12 | create_table :sortable_booleans do |t| 13 | t.column :value, :enquo_boolean 14 | t.string :notes 15 | end 16 | end 17 | end 18 | 19 | class CreateUnqueryableBooleans < ActiveRecord::Migration[6.0] 20 | def change 21 | create_table :unqueryable_booleans do |t| 22 | t.column :value, :enquo_boolean 23 | t.string :notes 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/migrations/create_dates.rb: -------------------------------------------------------------------------------- 1 | class CreateEnDates < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :en_dates do |t| 4 | t.column :value, :enquo_date 5 | t.string :notes 6 | end 7 | end 8 | end 9 | 10 | class CreateSortableDates < ActiveRecord::Migration[6.0] 11 | def change 12 | create_table :sortable_dates do |t| 13 | t.column :value, :enquo_date 14 | t.string :notes 15 | end 16 | end 17 | end 18 | 19 | class CreateUnqueryableDates < ActiveRecord::Migration[6.0] 20 | def change 21 | create_table :unqueryable_dates do |t| 22 | t.column :value, :enquo_date 23 | t.string :notes 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/migrations/create_texts.rb: -------------------------------------------------------------------------------- 1 | class CreateTexts < ActiveRecord::Migration[6.0] 2 | def change 3 | create_table :texts do |t| 4 | t.column :value, :enquo_text 5 | t.string :notes 6 | end 7 | end 8 | end 9 | 10 | class CreateSortableTexts < ActiveRecord::Migration[6.0] 11 | def change 12 | create_table :sortable_texts do |t| 13 | t.column :value, :enquo_text 14 | t.string :notes 15 | end 16 | end 17 | end 18 | 19 | class CreateUnqueryableTexts < ActiveRecord::Migration[6.0] 20 | def change 21 | create_table :unqueryable_texts do |t| 22 | t.column :value, :enquo_text 23 | t.string :notes 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/models/bigint.rb: -------------------------------------------------------------------------------- 1 | class Bigint < ActiveRecord::Base 2 | end 3 | 4 | class SortableBigint < ActiveRecord::Base 5 | enquo_attr :value, enable_reduced_security_operations: true 6 | end 7 | 8 | class UnqueryableBigint < ActiveRecord::Base 9 | enquo_attr :value, no_query: true 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/models/boolean.rb: -------------------------------------------------------------------------------- 1 | class Boolean < ActiveRecord::Base 2 | end 3 | 4 | class SortableBoolean < ActiveRecord::Base 5 | enquo_attr :value, enable_reduced_security_operations: true 6 | end 7 | 8 | class UnqueryableBoolean < ActiveRecord::Base 9 | enquo_attr :value, no_query: true 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/models/date.rb: -------------------------------------------------------------------------------- 1 | class EnDate < ActiveRecord::Base 2 | end 3 | 4 | class SortableDate < ActiveRecord::Base 5 | enquo_attr :value, enable_reduced_security_operations: true 6 | end 7 | 8 | class UnqueryableDate < ActiveRecord::Base 9 | enquo_attr :value, no_query: true 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/models/text.rb: -------------------------------------------------------------------------------- 1 | class Text < ActiveRecord::Base 2 | end 3 | 4 | class SortableText < ActiveRecord::Base 5 | enquo_attr :value, enable_reduced_security_operations: true 6 | end 7 | 8 | class UnqueryableText < ActiveRecord::Base 9 | enquo_attr :value, no_query: true 10 | end 11 | -------------------------------------------------------------------------------- /spec/text/insertion_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/text" 5 | 6 | def bind_param(value) 7 | ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) 8 | end 9 | 10 | shared_examples "an ORE-encrypted text" do |model, value, unsafe: false, no_query: false| 11 | let(:record) { model.new(value: value) } 12 | let(:db_value) do 13 | record.save! 14 | model.connection.select_one("SELECT value FROM #{model.arel_table.name} WHERE id=$1", nil, [bind_param(record.id)])["value"] 15 | end 16 | 17 | it "succeeds" do 18 | expect { record.save! }.to_not raise_error 19 | end 20 | 21 | it "stores the value as a JSON document" do 22 | expect { JSON.parse(db_value) }.to_not raise_error 23 | end 24 | 25 | let(:json_value) { JSON.parse(db_value, symbolize_names: true) } 26 | 27 | it "is a v1 ciphertext" do 28 | expect(json_value).to have_key(:v1) 29 | end 30 | 31 | it "has an AES ciphertext" do 32 | expect(json_value[:v1]).to have_key(:a) 33 | end 34 | 35 | it "has a key ID" do 36 | expect(json_value[:v1]).to have_key(:k) 37 | expect(json_value[:v1][:k]).to match([a_value_between(0, 255)] * 4) 38 | end 39 | 40 | let(:aes) { json_value[:v1][:a] } 41 | 42 | it "has a 96 bit IV" do 43 | expect(aes[:iv]).to match([a_value_between(0, 255)] * 12) 44 | end 45 | 46 | it "has a bytestring ciphertext" do 47 | expect(aes[:ct].length).to satisfy { |l| l >= 8 } 48 | expect(aes[:ct]).to all be_between(0, 255) 49 | end 50 | 51 | if no_query 52 | it "has no equality ciphertext" do 53 | expect(json_value[:v1][:e]).to be_nil 54 | end 55 | else 56 | it "has an equality ciphertext" do 57 | expect(json_value[:v1]).to have_key(:e) 58 | end 59 | 60 | let(:eqc) { json_value[:v1][:e] } 61 | 62 | if unsafe 63 | it "has a bytestring left equality ciphertext" do 64 | expect(eqc[:l].length).to eq(136) 65 | expect(eqc[:l]).to all be_between(0, 255) 66 | end 67 | 68 | it "has a numeric hash code" do 69 | expect(json_value[:v1]).to have_key(:h) 70 | expect(json_value[:v1][:h]).to be_between(0, 2**32-1) 71 | end 72 | else 73 | it "has no left equality ciphertext" do 74 | expect(eqc[:l]).to be_nil 75 | end 76 | end 77 | 78 | it "has a bytestring right equality ciphertext" do 79 | expect(eqc[:r].length).to eq(48) 80 | expect(eqc[:r]).to all be_between(0, 255) 81 | end 82 | end 83 | end 84 | 85 | describe "record insertion" do 86 | context "into text" do 87 | { 88 | "empty" => "", 89 | "short" => "Hello, Enquo!", 90 | "medium" => "The quick brown fox jumps over the lazy dog", 91 | "long" => (["Hello, Enquo!"] * 100).join("\n"), 92 | }.each do |desc, v| 93 | context "storing #{desc}" do 94 | it_behaves_like "an ORE-encrypted text", Text, v 95 | end 96 | end 97 | 98 | { 99 | "non-UTF8 string" => "\0\xff".force_encoding("BINARY"), 100 | "invalid UTF8 string" => "\0\xff".force_encoding("UTF-8"), 101 | }.each do |desc, v| 102 | context "storing #{desc}" do 103 | let(:model) { Text } 104 | let(:value) { v } 105 | 106 | it "explodes" do 107 | expect { Text.new(value: v).save! }.to raise_error(EncodingError) 108 | end 109 | end 110 | end 111 | 112 | { 113 | "a float" => 4.2, 114 | "an integer" => 42, 115 | "a random object" => Object.new, 116 | }.each do |desc, v| 117 | context "storing #{desc}" do 118 | let(:model) { Text } 119 | let(:value) { v } 120 | 121 | it "explodes" do 122 | expect { Text.new(value: v).save! }.to raise_error(TypeError) 123 | end 124 | end 125 | end 126 | end 127 | 128 | context "into unqueryable_text" do 129 | { 130 | "empty" => "", 131 | "short" => "Hello, Enquo!", 132 | "medium" => "The quick brown fox jumps over the lazy dog", 133 | "long" => (["Hello, Enquo!"] * 100).join("\n"), 134 | }.each do |desc, v| 135 | context "storing #{desc}" do 136 | it_behaves_like "an ORE-encrypted text", UnqueryableText, v, no_query: true 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /spec/text/retrieval_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require 'active_enquo' 3 | 4 | require_relative "../support/models/text" 5 | 6 | describe "record retrieval" do 7 | TEST_DATA = { 8 | "empty" => "", 9 | "short" => "Hello, Enquo!", 10 | "medium" => "The quick brown fox jumps over the lazy dog", 11 | "long" => (["Hello, Enquo!"] * 100).join("\n"), 12 | } 13 | 14 | [Text, UnqueryableText].each do |model| 15 | context "from #{model}" do 16 | before(:all) do 17 | model.create!(TEST_DATA.map { |k, v| { value: v, notes: k } }) 18 | end 19 | 20 | TEST_DATA.each do |k, v| 21 | it "decrypts a single #{k} record" do 22 | expect(model.where(notes: k).first.value).to eq(v) 23 | end 24 | end 25 | 26 | if model == UnqueryableText 27 | it "cannot be queried" do 28 | expect { model.where(value: "").first }.to raise_error(ActiveRecord::StatementInvalid) 29 | end 30 | else 31 | TEST_DATA.each do |k, v| 32 | it "retrieves and decrypts a single #{k} record" do 33 | expect(model.where(value: v).first.value).to eq(v) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | end 40 | --------------------------------------------------------------------------------