├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── activesupport71.gemfile └── activesupport72.gemfile ├── kms_encrypted.gemspec ├── lib ├── kms_encrypted.rb └── kms_encrypted │ ├── box.rb │ ├── client.rb │ ├── clients │ ├── aws.rb │ ├── base.rb │ ├── google.rb │ ├── test.rb │ └── vault.rb │ ├── database.rb │ ├── log_subscriber.rb │ ├── model.rb │ └── version.rb └── test ├── box_test.rb ├── client_test.rb ├── kms_encrypted_test.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - ruby: 3.4 11 | gemfile: Gemfile 12 | vault: true 13 | - ruby: 3.3 14 | gemfile: gemfiles/activesupport72.gemfile 15 | - ruby: 3.2 16 | gemfile: gemfiles/activesupport71.gemfile 17 | env: 18 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | - run: bundle exec rake test 26 | 27 | - if: ${{ matrix.vault }} 28 | run: | 29 | curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - 30 | sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" 31 | sudo apt-get update 32 | sudo apt-get install vault 33 | vault server -dev & 34 | sleep 1 35 | vault secrets enable transit 36 | vault write -f transit/keys/my-key derived=true 37 | vault audit enable file file_path=vault_audit.log 38 | bundle exec rake test 39 | env: 40 | KMS_KEY_ID: vault/my-key 41 | VAULT_ADDR: http://127.0.0.1:8200 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.lock 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.7.0 (2025-04-03) 2 | 3 | - Dropped support for Ruby < 3.2 and Rails < 7.1 4 | 5 | ## 1.6.0 (2024-06-24) 6 | 7 | - Dropped support for Ruby < 3.1 and Rails < 6.1 8 | 9 | ## 1.5.1 (2023-09-05) 10 | 11 | - Fixed deprecation warning with Active Support 7.1 12 | 13 | ## 1.5.0 (2023-04-09) 14 | 15 | - Added support for attr_encrypted 4 16 | - Dropped support for Ruby < 3 and Rails < 6 17 | 18 | ## 1.4.0 (2022-01-10) 19 | 20 | - Dropped support for Ruby < 2.6 and Rails < 5.2 21 | 22 | ## 1.3.0 (2021-10-10) 23 | 24 | - Added support for `google-cloud-kms` gem 25 | 26 | ## 1.2.4 (2021-06-20) 27 | 28 | - Fixed another argument error with Google Cloud KMS and Ruby 3 29 | 30 | ## 1.2.3 (2021-06-02) 31 | 32 | - Fixed argument error with Google Cloud KMS and Ruby 3 33 | 34 | ## 1.2.2 (2021-05-17) 35 | 36 | - Added `key_id` method 37 | 38 | ## 1.2.1 (2020-09-28) 39 | 40 | - Fixed `Version not active` error when switching keys 41 | 42 | ## 1.2.0 (2020-08-18) 43 | 44 | - Raise error when trying to rotate key used for encrypted files 45 | 46 | ## 1.1.1 (2020-04-16) 47 | 48 | - Fixed `SystemStackError` with `reload` and CarrierWave 49 | 50 | ## 1.1.0 (2019-07-09) 51 | 52 | - Added support for Lockbox 53 | - Dropped support for Rails 4.2 54 | 55 | ## 1.0.1 (2019-01-21) 56 | 57 | - Added support for encryption and decryption outside models 58 | - Added support for dynamic keys 59 | - Fixed issue with inheritance 60 | 61 | ## 1.0.0 (2018-12-17) 62 | 63 | - Added versioning 64 | - Added `context_hash` method 65 | 66 | Breaking changes 67 | 68 | - There’s now a default encryption context with the model name and id 69 | - ActiveSupport notifications were changed from `generate_data_key` and `decrypt_data_key` to `encrypt` and `decrypt` 70 | - AWS KMS uses the `Encrypt` operation instead of `GenerateDataKey` 71 | 72 | ## 0.3.0 (2018-11-11) 73 | 74 | - Added support for Vault 75 | - Removed `KmsEncrypted.kms_client` and `KmsEncrypted.client_options` in favor of `KmsEncrypted.aws_client` 76 | - Removed `KmsEncrypted::Google.kms_client` in favor of `KmsEncrypted.google_client` 77 | 78 | ## 0.2.0 (2018-02-23) 79 | 80 | - Added support for Google KMS 81 | 82 | ## 0.1.4 (2017-12-03) 83 | 84 | - Added `kms_keys` method to models 85 | - Reset data keys when record is reloaded 86 | - Added `kms_client` 87 | - Added ActiveSupport notifications 88 | 89 | ## 0.1.3 (2017-12-01) 90 | 91 | - Added test key 92 | - Added `client_options` 93 | - Allow private or protected `kms_encryption_context` method 94 | 95 | ## 0.1.2 (2017-09-25) 96 | 97 | - Use `KMS_KEY_ID` env variable by default 98 | 99 | ## 0.1.1 (2017-09-23) 100 | 101 | - Added key rotation 102 | - Added support for multiple keys per record 103 | 104 | ## 0.1.0 (2017-09-23) 105 | 106 | - First release 107 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activerecord", "~> 8.0.0" 8 | gem "attr_encrypted" 9 | gem "lockbox", ">= 1.4" 10 | gem "aws-sdk-kms" 11 | gem "nokogiri" 12 | # gem "google-apis-cloudkms_v1" 13 | gem "vault" 14 | gem "carrierwave" 15 | 16 | platform :ruby do 17 | gem "sqlite3" 18 | gem "pg" 19 | gem "google-cloud-kms" 20 | end 21 | 22 | platform :jruby do 23 | gem "sqlite3-ffi" 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2025 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KMS Encrypted 2 | 3 | Simple, secure key management for [Lockbox](https://github.com/ankane/lockbox) and [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted) 4 | 5 | With KMS Encrypted: 6 | 7 | - Master encryption keys are not on application servers 8 | - Encrypt and decrypt permissions can be granted separately 9 | - There’s an immutable audit log of all activity 10 | - Decryption can be disabled if an attack is detected 11 | - It’s easy to rotate keys 12 | 13 | Supports [AWS KMS](https://aws.amazon.com/kms/), [Google Cloud KMS](https://cloud.google.com/kms/), and [Vault](https://www.vaultproject.io/) 14 | 15 | Check out [this post](https://ankane.org/sensitive-data-rails) for more info on securing sensitive data with Rails 16 | 17 | [![Build Status](https://github.com/ankane/kms_encrypted/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/kms_encrypted/actions) 18 | 19 | ## How It Works 20 | 21 | This approach uses a key management service (KMS) to manage encryption keys and Lockbox / attr_encrypted to do the encryption. 22 | 23 | To encrypt an attribute, we first generate a data key and encrypt it with the KMS. This is known as [envelope encryption](https://cloud.google.com/kms/docs/envelope-encryption). We pass the unencrypted version to the encryption library and store the encrypted version in the `encrypted_kms_key` column. For each record, we generate a different data key. 24 | 25 | To decrypt an attribute, we first decrypt the data key with the KMS. Once we have the decrypted key, we pass it to the encryption library to decrypt the data. We can easily track decryptions since we have a different data key for each record. 26 | 27 | ## Installation 28 | 29 | Add this line to your application’s Gemfile: 30 | 31 | ```ruby 32 | gem "kms_encrypted" 33 | ``` 34 | 35 | And follow the instructions for your key management service: 36 | 37 | - [AWS KMS](#aws-kms) 38 | - [Google Cloud KMS](#google-cloud-kms) 39 | - [Vault](#vault) 40 | 41 | ### AWS KMS 42 | 43 | Add this line to your application’s Gemfile: 44 | 45 | ```ruby 46 | gem "aws-sdk-kms" 47 | ``` 48 | 49 | Create an [Amazon Web Services](https://aws.amazon.com/) account if you don’t have one. KMS works great whether or not you run your infrastructure on AWS. 50 | 51 | Create a [KMS master key](https://console.aws.amazon.com/kms/home#/kms/keys) and set it in your environment along with your AWS credentials ([dotenv](https://github.com/bkeepers/dotenv) is great for this) 52 | 53 | ```sh 54 | KMS_KEY_ID=arn:aws:kms:... 55 | AWS_ACCESS_KEY_ID=... 56 | AWS_SECRET_ACCESS_KEY=... 57 | ``` 58 | 59 | You can also use the alias 60 | 61 | ```sh 62 | KMS_KEY_ID=alias/my-alias 63 | ``` 64 | 65 | ### Google Cloud KMS 66 | 67 | Add this line to your application’s Gemfile: 68 | 69 | ```ruby 70 | gem "google-cloud-kms" 71 | ``` 72 | 73 | Create a [Google Cloud Platform](https://cloud.google.com/) account if you don’t have one. KMS works great whether or not you run your infrastructure on GCP. 74 | 75 | Create a [KMS key ring and key](https://console.cloud.google.com/iam-admin/kms) and set it in your environment along with your GCP credentials ([dotenv](https://github.com/bkeepers/dotenv) is great for this) 76 | 77 | ```sh 78 | KMS_KEY_ID=projects/my-project/locations/global/keyRings/my-key-ring/cryptoKeys/my-key 79 | ``` 80 | 81 | ### Vault 82 | 83 | Add this line to your application’s Gemfile: 84 | 85 | ```ruby 86 | gem "vault" 87 | ``` 88 | 89 | Enable the [transit](https://www.vaultproject.io/docs/secrets/transit/index.html) secrets engine 90 | 91 | ```sh 92 | vault secrets enable transit 93 | ``` 94 | 95 | And create a key 96 | 97 | ```sh 98 | vault write -f transit/keys/my-key derived=true 99 | ``` 100 | 101 | Set it in your environment along with your Vault credentials ([dotenv](https://github.com/bkeepers/dotenv) is great for this) 102 | 103 | ```sh 104 | KMS_KEY_ID=vault/my-key 105 | VAULT_ADDR=http://127.0.0.1:8200 106 | VAULT_TOKEN=secret 107 | ``` 108 | 109 | ## Getting Started 110 | 111 | Create a migration to add a column for the encrypted KMS data keys 112 | 113 | ```ruby 114 | add_column :users, :encrypted_kms_key, :text 115 | ``` 116 | 117 | And update your model 118 | 119 | ```ruby 120 | class User < ApplicationRecord 121 | has_kms_key 122 | 123 | # Lockbox fields 124 | has_encrypted :email, key: :kms_key 125 | 126 | # Lockbox files 127 | encrypts_attached :license, key: :kms_key 128 | 129 | # attr_encrypted fields 130 | attr_encrypted :email, key: :kms_key 131 | end 132 | ``` 133 | 134 | For each encrypted attribute, use the `kms_key` method for its key. 135 | 136 | ## Auditing & Alerting 137 | 138 | ### Context 139 | 140 | Encryption context is used in auditing to identify the data being decrypted. This is the model name and id by default. You can customize this with: 141 | 142 | ```ruby 143 | class User < ApplicationRecord 144 | def kms_encryption_context 145 | { 146 | model_name: model_name.to_s, 147 | model_id: id 148 | } 149 | end 150 | end 151 | ``` 152 | 153 | The context is used as part of the encryption and decryption process, so it must be a value that doesn’t change. Otherwise, you won’t be able to decrypt. You can [rotate the context](#switching-context) without downtime if needed. 154 | 155 | ### Order of Events 156 | 157 | Since the default context includes the id, the data key cannot be encrypted until the record has an id. For new records, the default flow is: 158 | 159 | 1. Start a database transaction 160 | 2. Insert the record, getting back the id 161 | 3. Call KMS to encrypt the data key, passing the id as part of the context 162 | 4. Update the `encrypted_kms_key` column 163 | 5. Commit the database transaction 164 | 165 | With Postgres, you can avoid a network call inside a transaction with: 166 | 167 | ```ruby 168 | class User < ApplicationRecord 169 | has_kms_key eager_encrypt: :fetch_id 170 | end 171 | ``` 172 | 173 | This changes the flow to: 174 | 175 | 1. Prefetch the id with the Postgres `nextval` function 176 | 2. Call KMS to encrypt the data key, passing the id as part of the context 177 | 3. Insert the record with the id and encrypted data key 178 | 179 | If you don’t need the id from the database for context, you can use: 180 | 181 | ```ruby 182 | class User < ApplicationRecord 183 | has_kms_key eager_encrypt: true 184 | end 185 | ``` 186 | 187 | ### AWS KMS 188 | 189 | [AWS CloudTrail](https://aws.amazon.com/cloudtrail/) logs all decryption calls. You can view them in the [CloudTrail console](https://console.aws.amazon.com/cloudtrail/home#/events?EventName=Decrypt). Note that it can take 20 minutes for events to show up. You can also use the AWS CLI. 190 | 191 | ```sh 192 | aws cloudtrail lookup-events --lookup-attributes AttributeKey=EventName,AttributeValue=Decrypt 193 | ``` 194 | 195 | If you haven’t already, enable CloudTrail storage to S3 to ensure events are accessible after 90 days. Later, you can use Amazon Athena and this [table structure](https://www.1strategy.com/blog/2017/07/25/auditing-aws-activity-with-cloudtrail-and-athena/) to query them. 196 | 197 | Read more about [encryption context here](https://docs.aws.amazon.com/kms/latest/developerguide/encryption-context.html). 198 | 199 | #### Alerting 200 | 201 | Set up alerts for suspicious behavior. To get near real-time alerts (20-30 second delay), use CloudWatch Events. 202 | 203 | First, create a new SNS topic with a name like "decryptions". We’ll use this shortly. 204 | 205 | Next, open [CloudWatch Events](https://console.aws.amazon.com/cloudwatch/home#rules:) and create a rule to match “Events by Service”. Choose “Key Management Service (KMS)” as the service name and “AWS API Call via CloudTrail” as the event type. For operations, select “Specific Operations” and enter “Decrypt”. 206 | 207 | Select the SNS topic created earlier as the target and save the rule. 208 | 209 | To set up an alarm, go to [this page](https://console.aws.amazon.com/cloudwatch/home?#metricsV2:graph=%7E();namespace=AWS/Events;dimensions=RuleName) in CloudWatch Metrics. Find the rule and check “Invocations”. On the “Graphed Metrics” tab, change the statistic to “Sum” and the period to “1 minute”. Finally, click the bell icon to create an alarm for high number of decryptions. 210 | 211 | While the alarm we created isn’t super sophisticated, this setup provides a great foundation for alerting as your organization grows. 212 | 213 | You can use the SNS topic or another target to send events to a log provider or [SIEM](https://en.wikipedia.org/wiki/Security_information_and_event_management), where can you do more advanced anomaly detection. 214 | 215 | You should also use other tools to detect breaches, like an [IDS](https://www.alienvault.com/blogs/security-essentials/open-source-intrusion-detection-tools-a-quick-overview). You can use [Amazon GuardDuty](https://aws.amazon.com/guardduty/) if you run infrastructure on AWS. 216 | 217 | ### Google Cloud KMS 218 | 219 | Follow the [instructions here](https://cloud.google.com/kms/docs/logging) to set up data access logging. There is not currently a way to see what data is being decrypted, since the additional authenticated data is not logged. For this reason, we recommend another KMS provider. 220 | 221 | ### Vault 222 | 223 | Follow the [instructions here](https://www.vaultproject.io/docs/audit/) to set up data access logging. 224 | 225 | **Note:** Vault will only verify this value if `derived` was set to true when creating the key. If this is not done, the context cannot be trusted. 226 | 227 | Context will show up hashed in the audit logs. To get the hash for a record, use: 228 | 229 | ```ruby 230 | KmsEncrypted.context_hash(record.kms_encryption_context, path: "file") 231 | ``` 232 | 233 | The `path` option should point to your audit device. Common paths are `file`, `syslog`, and `socket`. 234 | 235 | ## Separate Permissions 236 | 237 | A great feature of KMS is the ability to grant encryption and decryption permission separately. 238 | 239 | Be extremely selective of servers you allow to decrypt. 240 | 241 | For servers that can only encrypt, clear out the existing data and data key before assigning new values (otherwise, you’ll get a decryption error). 242 | 243 | ```ruby 244 | # Lockbox 245 | user.email_ciphertext = nil 246 | user.encrypted_kms_key = nil 247 | 248 | # attr_encrypted 249 | user.encrypted_email = nil 250 | user.encrypted_email_iv = nil 251 | user.encrypted_kms_key = nil 252 | ``` 253 | 254 | ### AWS KMS 255 | 256 | To encrypt the data, use an IAM policy with: 257 | 258 | ```json 259 | { 260 | "Version": "2012-10-17", 261 | "Statement": [ 262 | { 263 | "Sid": "EncryptData", 264 | "Effect": "Allow", 265 | "Action": "kms:Encrypt", 266 | "Resource": "arn:aws:kms:..." 267 | } 268 | ] 269 | } 270 | ``` 271 | 272 | To decrypt the data, use an IAM policy with: 273 | 274 | ```json 275 | { 276 | "Version": "2012-10-17", 277 | "Statement": [ 278 | { 279 | "Sid": "DecryptData", 280 | "Effect": "Allow", 281 | "Action": "kms:Decrypt", 282 | "Resource": "arn:aws:kms:..." 283 | } 284 | ] 285 | } 286 | ``` 287 | 288 | ### Google Cloud KMS 289 | 290 | todo: document 291 | 292 | ### Vault 293 | 294 | To encrypt the data, use a policy with: 295 | 296 | ```hcl 297 | path "transit/encrypt/my-key" 298 | { 299 | capabilities = ["create", "update"] 300 | } 301 | ``` 302 | 303 | To decrypt the data, use a policy with: 304 | 305 | ```hcl 306 | path "transit/decrypt/my-key" 307 | { 308 | capabilities = ["create", "update"] 309 | } 310 | ``` 311 | 312 | Apply a policy with: 313 | 314 | ```sh 315 | vault policy write encrypt encrypt.hcl 316 | ``` 317 | 318 | And create a token with specific policies with: 319 | 320 | ```sh 321 | vault token create -policy=encrypt -policy=decrypt -no-default-policy 322 | ``` 323 | 324 | ## Testing 325 | 326 | For testing, you can prevent network calls to KMS by setting: 327 | 328 | ```sh 329 | KMS_KEY_ID=insecure-test-key 330 | ``` 331 | 332 | In a Rails application, you can also create `config/initializers/kms_encrypted.rb` with: 333 | 334 | ```ruby 335 | KmsEncrypted.key_id = Rails.env.test? ? "insecure-test-key" : ENV["KMS_KEY_ID"] 336 | ``` 337 | 338 | ## Key Rotation 339 | 340 | Key management services allow you to rotate the master key without any code changes. 341 | 342 | - For AWS KMS, you can use [automatic key rotation](https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html) 343 | - For Google Cloud, use the Google Cloud Console or API 344 | - For Vault, use: 345 | 346 | ```sh 347 | vault write -f transit/keys/my-key/rotate 348 | ``` 349 | 350 | New data will be encrypted with the new master key version. To encrypt existing data with new master key version, run: 351 | 352 | ```ruby 353 | User.find_each do |user| 354 | user.rotate_kms_key! 355 | end 356 | ``` 357 | 358 | **Note:** This method does not rotate encrypted files, so avoid calling `rotate_kms_key!` on models with file uploads for now. 359 | 360 | ### Switching Keys 361 | 362 | You can change keys within your current KMS or move to a different KMS without downtime. Update your model: 363 | 364 | ```ruby 365 | class User < ApplicationRecord 366 | has_kms_key version: 2, key_id: ENV["KMS_KEY_ID_V2"], 367 | previous_versions: { 368 | 1 => {key_id: ENV["KMS_KEY_ID"]} 369 | } 370 | end 371 | ``` 372 | 373 | New data will be encrypted with the new key. To update existing data, use: 374 | 375 | ```ruby 376 | User.where("encrypted_kms_key NOT LIKE 'v2:%'").find_each do |user| 377 | user.rotate_kms_key! 378 | end 379 | ``` 380 | 381 | Once all data is updated, you can remove the `previous_versions` option. 382 | 383 | ### Switching Context 384 | 385 | You can change your encryption context without downtime. Update your model: 386 | 387 | ```ruby 388 | class User < ApplicationRecord 389 | has_kms_key version: 2, 390 | previous_versions: { 391 | 1 => {key_id: ENV["KMS_KEY_ID"]} 392 | } 393 | 394 | def kms_encryption_context(version:) 395 | if version == 1 396 | # previous context method 397 | else 398 | # new context method 399 | end 400 | end 401 | end 402 | ``` 403 | 404 | New data will be encrypted with the new context. To update existing data, use: 405 | 406 | ```ruby 407 | User.where("encrypted_kms_key NOT LIKE 'v2:%'").find_each do |user| 408 | user.rotate_kms_key! 409 | end 410 | ``` 411 | 412 | Once all data is updated, you can remove the `previous_versions` option. 413 | 414 | ## Multiple Keys Per Record 415 | 416 | You may want to protect different columns with different data keys (or even master keys). 417 | 418 | To do this, add another column 419 | 420 | ```ruby 421 | add_column :users, :encrypted_kms_key_phone, :text 422 | ``` 423 | 424 | And update your model 425 | 426 | ```ruby 427 | class User < ApplicationRecord 428 | has_kms_key 429 | has_kms_key name: :phone, key_id: "..." 430 | 431 | # Lockbox 432 | has_encrypted :email, key: :kms_key 433 | has_encrypted :phone, key: :kms_key_phone 434 | 435 | # attr_encrypted 436 | attr_encrypted :email, key: :kms_key 437 | attr_encrypted :phone, key: :kms_key_phone 438 | end 439 | ``` 440 | 441 | To rotate keys, use: 442 | 443 | ```ruby 444 | user.rotate_kms_key_phone! 445 | ``` 446 | 447 | For custom context, use: 448 | 449 | ```ruby 450 | class User < ApplicationRecord 451 | def kms_encryption_context_phone 452 | # some hash 453 | end 454 | end 455 | ``` 456 | 457 | ## Outside Models 458 | 459 | To encrypt and decrypt outside of models, create a box: 460 | 461 | ```ruby 462 | kms = KmsEncrypted::Box.new 463 | ``` 464 | 465 | You can pass `key_id`, `version`, and `previous_versions` if needed. 466 | 467 | Encrypt 468 | 469 | ```ruby 470 | kms.encrypt(message, context: {model_name: "User", model_id: 123}) 471 | ``` 472 | 473 | Decrypt 474 | 475 | ```ruby 476 | kms.decrypt(ciphertext, context: {model_name: "User", model_id: 123}) 477 | ``` 478 | 479 | ## Related Projects 480 | 481 | To securely search encrypted data, check out [Blind Index](https://github.com/ankane/blind_index). 482 | 483 | ## History 484 | 485 | View the [changelog](CHANGELOG.md) 486 | 487 | ## Contributing 488 | 489 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 490 | 491 | - [Report bugs](https://github.com/ankane/kms_encrypted/issues) 492 | - Fix bugs and [submit pull requests](https://github.com/ankane/kms_encrypted/pulls) 493 | - Write, clarify, or fix documentation 494 | - Suggest or add new features 495 | 496 | To get started with development and testing: 497 | 498 | ```sh 499 | git clone https://github.com/ankane/kms_encrypted.git 500 | cd kms_encrypted 501 | bundle install 502 | bundle exec rake test 503 | ``` 504 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.test_files = FileList["test/**/*_test.rb"] 7 | t.warning = false 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /gemfiles/activesupport71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activerecord", "~> 7.1.0" 8 | gem "sqlite3", "< 2" 9 | gem "attr_encrypted" 10 | gem "lockbox", ">= 1" 11 | gem "aws-sdk-kms" 12 | gem "nokogiri" 13 | gem "google-apis-cloudkms_v1" 14 | gem "vault" 15 | gem "carrierwave" 16 | -------------------------------------------------------------------------------- /gemfiles/activesupport72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest" 7 | gem "activerecord", "~> 7.2.0" 8 | gem "sqlite3" 9 | gem "attr_encrypted" 10 | gem "lockbox", ">= 1.4" 11 | gem "aws-sdk-kms" 12 | gem "nokogiri" 13 | gem "google-apis-cloudkms_v1" 14 | gem "vault" 15 | gem "carrierwave" 16 | -------------------------------------------------------------------------------- /kms_encrypted.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/kms_encrypted/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "kms_encrypted" 5 | spec.version = KmsEncrypted::VERSION 6 | spec.summary = "Simple, secure key management for Lockbox and attr_encrypted" 7 | spec.homepage = "https://github.com/ankane/kms_encrypted" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{lib}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "activesupport", ">= 7.1" 19 | spec.add_dependency "base64" 20 | end 21 | -------------------------------------------------------------------------------- /lib/kms_encrypted.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "active_support" 3 | require "base64" 4 | require "json" 5 | require "securerandom" 6 | 7 | # modules 8 | require_relative "kms_encrypted/box" 9 | require_relative "kms_encrypted/database" 10 | require_relative "kms_encrypted/log_subscriber" 11 | require_relative "kms_encrypted/model" 12 | require_relative "kms_encrypted/version" 13 | 14 | # clients 15 | require_relative "kms_encrypted/client" 16 | require_relative "kms_encrypted/clients/base" 17 | require_relative "kms_encrypted/clients/aws" 18 | require_relative "kms_encrypted/clients/google" 19 | require_relative "kms_encrypted/clients/test" 20 | require_relative "kms_encrypted/clients/vault" 21 | 22 | module KmsEncrypted 23 | class Error < StandardError; end 24 | class DecryptionError < Error; end 25 | 26 | class << self 27 | attr_writer :aws_client 28 | attr_writer :google_client 29 | attr_writer :vault_client 30 | attr_writer :key_id 31 | 32 | def aws_client 33 | @aws_client ||= Aws::KMS::Client.new( 34 | retry_limit: 1, 35 | http_open_timeout: 2, 36 | http_read_timeout: 2 37 | ) 38 | end 39 | 40 | def google_client 41 | @google_client ||= begin 42 | begin 43 | require "google/apis/cloudkms_v1" 44 | 45 | client = ::Google::Apis::CloudkmsV1::CloudKMSService.new 46 | client.authorization = ::Google::Auth.get_application_default( 47 | "https://www.googleapis.com/auth/cloud-platform" 48 | ) 49 | client.client_options.log_http_requests = false 50 | client.client_options.open_timeout_sec = 2 51 | client.client_options.read_timeout_sec = 2 52 | client 53 | rescue LoadError 54 | require "google/cloud/kms" 55 | 56 | Google::Cloud::Kms.key_management_service do |config| 57 | config.timeout = 2 58 | end 59 | end 60 | end 61 | end 62 | 63 | def vault_client 64 | @vault_client ||= ::Vault::Client.new 65 | end 66 | 67 | def key_id 68 | @key_id ||= ENV["KMS_KEY_ID"] 69 | end 70 | 71 | # hash is independent of key, but specific to audit device 72 | def context_hash(context, path:) 73 | context = Base64.encode64(context.to_json) 74 | vault_client.logical.write("sys/audit-hash/#{path}", input: context).data[:hash] 75 | end 76 | end 77 | end 78 | 79 | ActiveSupport.on_load(:active_record) do 80 | extend KmsEncrypted::Model 81 | end 82 | 83 | KmsEncrypted::LogSubscriber.attach_to :kms_encrypted 84 | -------------------------------------------------------------------------------- /lib/kms_encrypted/box.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | class Box 3 | attr_reader :key_id, :version, :previous_versions 4 | 5 | def initialize(key_id: nil, version: nil, previous_versions: nil) 6 | @key_id = key_id || KmsEncrypted.key_id 7 | @version = version || 1 8 | @previous_versions = previous_versions || {} 9 | end 10 | 11 | def encrypt(plaintext, context: nil) 12 | context = version_context(context, version) 13 | key_id = version_key_id(version) 14 | ciphertext = KmsEncrypted::Client.new(key_id: key_id, data_key: true).encrypt(plaintext, context: context) 15 | "v#{version}:#{encode64(ciphertext)}" 16 | end 17 | 18 | def decrypt(ciphertext, context: nil) 19 | m = /\Av(\d+):/.match(ciphertext) 20 | if m 21 | version = m[1].to_i 22 | ciphertext = ciphertext.sub("v#{version}:", "") 23 | else 24 | version = 1 25 | legacy_context = true 26 | 27 | # legacy 28 | if ciphertext.start_with?("$gc$") 29 | _, _, short_key_id, ciphertext = ciphertext.split("$", 4) 30 | 31 | # restore key, except for cryptoKeyVersion 32 | stored_key_id = decode64(short_key_id).split("/")[0..3] 33 | stored_key_id.insert(0, "projects") 34 | stored_key_id.insert(2, "locations") 35 | stored_key_id.insert(4, "keyRings") 36 | stored_key_id.insert(6, "cryptoKeys") 37 | key_id = stored_key_id.join("/") 38 | elsif ciphertext.start_with?("vault:") 39 | ciphertext = Base64.encode64(ciphertext) 40 | end 41 | end 42 | 43 | key_id ||= version_key_id(version) 44 | ciphertext = decode64(ciphertext) 45 | context = version_context(context, version) 46 | 47 | KmsEncrypted::Client.new( 48 | key_id: key_id, 49 | data_key: true, 50 | legacy_context: legacy_context 51 | ).decrypt(ciphertext, context: context) 52 | end 53 | 54 | private 55 | 56 | def version_key_id(version) 57 | key_id = 58 | if previous_versions[version] 59 | previous_versions[version][:key_id] 60 | elsif self.version == version 61 | self.key_id 62 | else 63 | raise KmsEncrypted::Error, "Version not active: #{version}" 64 | end 65 | 66 | raise ArgumentError, "Missing key id" unless key_id 67 | 68 | key_id 69 | end 70 | 71 | def version_context(context, version) 72 | if context.respond_to?(:call) 73 | if context.arity == 0 74 | context.call 75 | else 76 | context.call(version) 77 | end 78 | else 79 | context 80 | end 81 | end 82 | 83 | def encode64(bytes) 84 | Base64.strict_encode64(bytes) 85 | end 86 | 87 | def decode64(bytes) 88 | Base64.decode64(bytes) 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/kms_encrypted/client.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | class Client 3 | attr_reader :key_id, :data_key 4 | 5 | def initialize(key_id: nil, legacy_context: false, data_key: false) 6 | @key_id = key_id || KmsEncrypted.key_id 7 | @legacy_context = legacy_context 8 | @data_key = data_key 9 | end 10 | 11 | def encrypt(plaintext, context: nil) 12 | event = { 13 | key_id: key_id, 14 | context: context, 15 | data_key: data_key 16 | } 17 | 18 | ActiveSupport::Notifications.instrument("encrypt.kms_encrypted", event) do 19 | client.encrypt(plaintext, context: context) 20 | end 21 | end 22 | 23 | def decrypt(ciphertext, context: nil) 24 | event = { 25 | key_id: key_id, 26 | context: context, 27 | data_key: data_key 28 | } 29 | 30 | ActiveSupport::Notifications.instrument("decrypt.kms_encrypted", event) do 31 | client.decrypt(ciphertext, context: context) 32 | end 33 | end 34 | 35 | private 36 | 37 | def provider 38 | if key_id == "insecure-test-key" 39 | :test 40 | elsif key_id.start_with?("vault/") 41 | :vault 42 | elsif key_id.start_with?("projects/") 43 | :google 44 | else 45 | :aws 46 | end 47 | end 48 | 49 | def client 50 | @client ||= begin 51 | klass = 52 | case provider 53 | when :test 54 | KmsEncrypted::Clients::Test 55 | when :vault 56 | KmsEncrypted::Clients::Vault 57 | when :google 58 | KmsEncrypted::Clients::Google 59 | else 60 | KmsEncrypted::Clients::Aws 61 | end 62 | 63 | klass.new(key_id: key_id, legacy_context: @legacy_context) 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/kms_encrypted/clients/aws.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Clients 3 | class Aws < Base 4 | def encrypt(plaintext, context: nil) 5 | options = { 6 | key_id: key_id, 7 | plaintext: plaintext 8 | } 9 | options[:encryption_context] = generate_context(context) if context 10 | 11 | KmsEncrypted.aws_client.encrypt(options).ciphertext_blob 12 | end 13 | 14 | def decrypt(ciphertext, context: nil) 15 | options = { 16 | ciphertext_blob: ciphertext 17 | } 18 | options[:encryption_context] = generate_context(context) if context 19 | 20 | begin 21 | KmsEncrypted.aws_client.decrypt(options).plaintext 22 | rescue ::Aws::KMS::Errors::InvalidCiphertextException 23 | decryption_failed! 24 | end 25 | end 26 | 27 | private 28 | 29 | # make integers strings for convenience 30 | def generate_context(context) 31 | raise ArgumentError, "Context must be a hash" unless context.is_a?(Hash) 32 | Hash[context.map { |k, v| [k, context_value(v)] }] 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/kms_encrypted/clients/base.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Clients 3 | class Base 4 | attr_reader :key_id 5 | 6 | def initialize(key_id: nil, legacy_context: false) 7 | @key_id = key_id 8 | @legacy_context = legacy_context 9 | end 10 | 11 | protected 12 | 13 | def decryption_failed! 14 | raise DecryptionError, "Decryption failed" 15 | end 16 | 17 | # keys must be ordered consistently 18 | # values are checked for validity 19 | # then converted to strings 20 | def generate_context(context) 21 | if @legacy_context 22 | context.to_json 23 | elsif context.is_a?(Hash) 24 | Hash[context.sort_by { |k| k.to_s }.map { |k, v| [context_key(k), context_value(v)] }].to_json 25 | else 26 | context 27 | end 28 | end 29 | 30 | def context_key(k) 31 | unless k.is_a?(String) || k.is_a?(Symbol) 32 | raise ArgumentError, "Context keys must be a string or symbol" 33 | end 34 | k.to_s 35 | end 36 | 37 | def context_value(v) 38 | unless v.is_a?(String) || v.is_a?(Integer) 39 | raise ArgumentError, "Context values must be a string or integer" 40 | end 41 | v.to_s 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/kms_encrypted/clients/google.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Clients 3 | class Google < Base 4 | attr_reader :last_key_version 5 | 6 | def encrypt(plaintext, context: nil) 7 | options = { 8 | plaintext: plaintext 9 | } 10 | options[:additional_authenticated_data] = generate_context(context) if context 11 | 12 | # ensure namespace gets loaded 13 | client = KmsEncrypted.google_client 14 | 15 | if defined?(::Google::Apis::CloudkmsV1::CloudKMSService) && KmsEncrypted.google_client.is_a?(::Google::Apis::CloudkmsV1::CloudKMSService) 16 | request = ::Google::Apis::CloudkmsV1::EncryptRequest.new(**options) 17 | response = client.encrypt_crypto_key(key_id, request) 18 | @last_key_version = response.name 19 | response.ciphertext 20 | else 21 | options[:name] = key_id 22 | response = client.encrypt(**options) 23 | @last_key_version = response.name 24 | response.ciphertext 25 | end 26 | end 27 | 28 | def decrypt(ciphertext, context: nil) 29 | options = { 30 | ciphertext: ciphertext 31 | } 32 | options[:additional_authenticated_data] = generate_context(context) if context 33 | 34 | # ensure namespace gets loaded 35 | client = KmsEncrypted.google_client 36 | 37 | if defined?(::Google::Apis::CloudkmsV1::CloudKMSService) && KmsEncrypted.google_client.is_a?(::Google::Apis::CloudkmsV1::CloudKMSService) 38 | request = ::Google::Apis::CloudkmsV1::DecryptRequest.new(**options) 39 | begin 40 | client.decrypt_crypto_key(key_id, request).plaintext 41 | rescue ::Google::Apis::ClientError => e 42 | decryption_failed! if e.message.include?("Decryption failed") 43 | raise e 44 | end 45 | else 46 | options[:name] = key_id 47 | begin 48 | client.decrypt(**options).plaintext 49 | rescue ::Google::Cloud::InvalidArgumentError => e 50 | decryption_failed! if e.message.include?("Decryption failed") 51 | raise e 52 | end 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/kms_encrypted/clients/test.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Clients 3 | class Test < Base 4 | PREFIX = Base64.decode64("insecure+data+A") 5 | 6 | def encrypt(plaintext, context: nil) 7 | parts = [PREFIX, Base64.strict_encode64(plaintext)] 8 | parts << generate_context(context) if context 9 | parts.join(":") 10 | end 11 | 12 | def decrypt(ciphertext, context: nil) 13 | prefix, plaintext, stored_context = ciphertext.split(":") 14 | 15 | decryption_failed! if prefix != PREFIX 16 | 17 | context = generate_context(context) if context 18 | decryption_failed! if context != stored_context 19 | 20 | Base64.decode64(plaintext) 21 | end 22 | 23 | private 24 | 25 | # turn hash into json 26 | def generate_context(context) 27 | Base64.encode64(super) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/kms_encrypted/clients/vault.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Clients 3 | class Vault < Base 4 | def encrypt(plaintext, context: nil) 5 | options = { 6 | plaintext: Base64.encode64(plaintext) 7 | } 8 | options[:context] = generate_context(context) if context 9 | 10 | response = KmsEncrypted.vault_client.logical.write( 11 | "transit/encrypt/#{key_id.sub("vault/", "")}", 12 | options 13 | ) 14 | 15 | response.data[:ciphertext] 16 | end 17 | 18 | def decrypt(ciphertext, context: nil) 19 | options = { 20 | ciphertext: ciphertext 21 | } 22 | options[:context] = generate_context(context) if context 23 | 24 | response = 25 | begin 26 | KmsEncrypted.vault_client.logical.write( 27 | "transit/decrypt/#{key_id.sub("vault/", "")}", 28 | options 29 | ) 30 | rescue ::Vault::HTTPClientError => e 31 | decryption_failed! if e.message.include?("unable to decrypt") || e.message.include?("message authentication failed") 32 | raise e 33 | rescue ::Vault::HTTPServerError => e 34 | decryption_failed! if e.message.include?("message authentication failed") 35 | raise e 36 | rescue Encoding::UndefinedConversionError 37 | decryption_failed! 38 | end 39 | 40 | Base64.decode64(response.data[:plaintext]) 41 | end 42 | 43 | private 44 | 45 | # turn hash into json 46 | def generate_context(context) 47 | Base64.encode64(super) 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/kms_encrypted/database.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | class Database 3 | attr_reader :record, :key_method, :options 4 | 5 | def initialize(record, key_method) 6 | @record = record 7 | @key_method = key_method 8 | @options = record.class.kms_keys[key_method.to_sym] 9 | end 10 | 11 | def version 12 | @version ||= evaluate_option(:version).to_i 13 | end 14 | 15 | def key_id 16 | @key_id ||= evaluate_option(:key_id) 17 | end 18 | 19 | def previous_versions 20 | @previous_versions ||= evaluate_option(:previous_versions) 21 | end 22 | 23 | def context(version) 24 | name = options[:name] 25 | context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context" 26 | if record.method(context_method).arity == 0 27 | record.send(context_method) 28 | else 29 | record.send(context_method, version: version) 30 | end 31 | end 32 | 33 | def encrypt(plaintext) 34 | context = context(version) 35 | 36 | KmsEncrypted::Box.new( 37 | key_id: key_id, 38 | version: version, 39 | previous_versions: previous_versions 40 | ).encrypt(plaintext, context: context) 41 | end 42 | 43 | def decrypt(ciphertext) 44 | # determine version for context 45 | m = /\Av(\d+):/.match(ciphertext) 46 | ciphertext_version = m ? m[1].to_i : 1 47 | context = (options[:upgrade_context] && !m) ? {} : context(ciphertext_version) 48 | 49 | KmsEncrypted::Box.new( 50 | key_id: key_id, 51 | version: version, 52 | previous_versions: previous_versions 53 | ).decrypt(ciphertext, context: context) 54 | end 55 | 56 | private 57 | 58 | def evaluate_option(key) 59 | opt = options[key] 60 | opt = record.instance_exec(&opt) if opt.respond_to?(:call) 61 | opt 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/kms_encrypted/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | class LogSubscriber < ActiveSupport::LogSubscriber 3 | def decrypt(event) 4 | return unless logger.debug? 5 | 6 | data_key = event.payload[:data_key] 7 | name = data_key ? "Decrypt Data Key" : "Decrypt" 8 | name += " (#{event.duration.round(1)}ms)" 9 | context = event.payload[:context] 10 | context = context.inspect if context.is_a?(Hash) 11 | debug " #{color(name, YELLOW, bold: true)} Context: #{context}" 12 | end 13 | 14 | def encrypt(event) 15 | return unless logger.debug? 16 | 17 | data_key = event.payload[:data_key] 18 | name = data_key ? "Encrypt Data Key" : "Encrypt" 19 | name += " (#{event.duration.round(1)}ms)" 20 | context = event.payload[:context] 21 | context = context.inspect if context.is_a?(Hash) 22 | debug " #{color(name, YELLOW, bold: true)} Context: #{context}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/kms_encrypted/model.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | module Model 3 | def has_kms_key(name: nil, key_id: nil, eager_encrypt: false, version: 1, previous_versions: nil, upgrade_context: false) 4 | key_id ||= KmsEncrypted.key_id 5 | 6 | key_method = name ? "kms_key_#{name}" : "kms_key" 7 | key_column = "encrypted_#{key_method}" 8 | context_method = name ? "kms_encryption_context_#{name}" : "kms_encryption_context" 9 | 10 | class_eval do 11 | @kms_keys ||= {} 12 | 13 | unless respond_to?(:kms_keys) 14 | def self.kms_keys 15 | parent_keys = 16 | if superclass.respond_to?(:kms_keys) 17 | superclass.kms_keys 18 | else 19 | {} 20 | end 21 | 22 | parent_keys.merge(@kms_keys || {}) 23 | end 24 | end 25 | 26 | @kms_keys[key_method.to_sym] = { 27 | key_id: key_id, 28 | name: name, 29 | version: version, 30 | previous_versions: previous_versions, 31 | upgrade_context: upgrade_context 32 | } 33 | 34 | if @kms_keys.size == 1 35 | after_save :encrypt_kms_keys 36 | 37 | # fetch all keys together so only need to update database once 38 | def encrypt_kms_keys 39 | updates = {} 40 | self.class.kms_keys.each do |key_method, key| 41 | instance_var = "@#{key_method}" 42 | key_column = "encrypted_#{key_method}" 43 | plaintext_key = instance_variable_get(instance_var) 44 | 45 | if !send(key_column) && plaintext_key 46 | updates[key_column] = KmsEncrypted::Database.new(self, key_method).encrypt(plaintext_key) 47 | end 48 | end 49 | if updates.any? 50 | current_time = current_time_from_proper_timezone 51 | timestamp_attributes_for_update_in_model.each do |attr| 52 | updates[attr] = current_time 53 | end 54 | update_columns(updates) 55 | end 56 | end 57 | 58 | if method_defined?(:reload) 59 | m = Module.new do 60 | define_method(:reload) do |*args, &block| 61 | result = super(*args, &block) 62 | self.class.kms_keys.keys.each do |key_method| 63 | instance_variable_set("@#{key_method}", nil) 64 | end 65 | result 66 | end 67 | end 68 | prepend m 69 | end 70 | end 71 | 72 | define_method(key_method) do 73 | instance_var = "@#{key_method}" 74 | 75 | unless instance_variable_get(instance_var) 76 | encrypted_key = send(key_column) 77 | plaintext_key = 78 | if encrypted_key 79 | KmsEncrypted::Database.new(self, key_method).decrypt(encrypted_key) 80 | else 81 | key = SecureRandom.random_bytes(32) 82 | 83 | if eager_encrypt == :fetch_id 84 | unless self.class.connection_db_config.adapter.to_s.match?(/postg/i) 85 | raise ArgumentError, ":fetch_id only works with Postgres" 86 | end 87 | 88 | sequence_name = self.class.sequence_name 89 | self.id ||= self.class.connection_pool.with_connection { |c| c.execute("select nextval('#{sequence_name}')").first["nextval"] } 90 | end 91 | 92 | if eager_encrypt == true || ([:try, :fetch_id].include?(eager_encrypt) && id) 93 | encrypted_key = KmsEncrypted::Database.new(self, key_method).encrypt(key) 94 | send("#{key_column}=", encrypted_key) 95 | end 96 | 97 | key 98 | end 99 | instance_variable_set(instance_var, plaintext_key) 100 | end 101 | 102 | instance_variable_get(instance_var) 103 | end 104 | 105 | define_method(context_method) do 106 | raise KmsEncrypted::Error, "id needed for encryption context" unless id 107 | 108 | { 109 | model_name: model_name.to_s, 110 | model_id: id 111 | } 112 | end 113 | 114 | # automatically detects attributes and files where the encryption key is: 115 | # 1. a symbol that matches kms key method exactly 116 | # does not detect attributes and files where the encryption key is: 117 | # 1. callable (warns) 118 | # 2. a symbol that internally calls kms key method 119 | # it could try to get the exact key and compare 120 | # (there's a very small chance this could have false positives) 121 | # but bias towards simplicity for now 122 | # TODO possibly raise error for callable keys in 2.0 123 | # with option to override/specify attributes 124 | define_method("rotate_#{key_method}!") do 125 | # decrypt 126 | plaintext_attributes = {} 127 | 128 | # attr_encrypted 129 | encrypted_attributes_method = 130 | if defined?(AttrEncrypted::Version::MAJOR) && AttrEncrypted::Version::MAJOR >= 4 131 | :attr_encrypted_encrypted_attributes 132 | else 133 | :encrypted_attributes 134 | end 135 | if self.class.respond_to?(encrypted_attributes_method) 136 | self.class.send(encrypted_attributes_method).to_a.each do |key, v| 137 | if v[:key] == key_method.to_sym 138 | plaintext_attributes[key] = send(key) 139 | elsif v[:key].respond_to?(:call) 140 | warn "[kms_encrypted] Can't detect if encrypted attribute uses this key" 141 | end 142 | end 143 | end 144 | 145 | # lockbox attributes 146 | # only checks key, not previous versions 147 | if self.class.respond_to?(:lockbox_attributes) 148 | self.class.lockbox_attributes.each do |key, v| 149 | if v[:key] == key_method.to_sym 150 | plaintext_attributes[key] = send(key) 151 | elsif v[:key].respond_to?(:call) 152 | warn "[kms_encrypted] Can't detect if encrypted attribute uses this key" 153 | end 154 | end 155 | end 156 | 157 | # lockbox attachments 158 | # only checks key, not previous versions 159 | if self.class.respond_to?(:lockbox_attachments) 160 | self.class.lockbox_attachments.each do |key, v| 161 | if v[:key] == key_method.to_sym 162 | # can likely add support at some point, but may be complicated 163 | # ideally use rotate_encryption! from Lockbox 164 | # but needs access to both old and new keys 165 | # also need to update database atomically 166 | raise KmsEncrypted::Error, "Can't rotate key used for encrypted files" 167 | elsif v[:key].respond_to?(:call) 168 | warn "[kms_encrypted] Can't detect if encrypted attachment uses this key" 169 | end 170 | end 171 | end 172 | 173 | # CarrierWave uploaders 174 | if self.class.respond_to?(:uploaders) 175 | self.class.uploaders.each do |_, uploader| 176 | # for simplicity, only checks if key is callable 177 | if uploader.respond_to?(:lockbox_options) && uploader.lockbox_options[:key].respond_to?(:call) 178 | warn "[kms_encrypted] Can't detect if encrypted uploader uses this key" 179 | end 180 | end 181 | end 182 | 183 | # reset key 184 | instance_variable_set("@#{key_method}", nil) 185 | send("encrypted_#{key_method}=", nil) 186 | 187 | # encrypt again 188 | plaintext_attributes.each do |attr, value| 189 | send("#{attr}=", value) 190 | end 191 | 192 | # update atomically 193 | save! 194 | end 195 | end 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /lib/kms_encrypted/version.rb: -------------------------------------------------------------------------------- 1 | module KmsEncrypted 2 | VERSION = "1.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/box_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class BoxTest < Minitest::Test 4 | def test_encrypt 5 | client = KmsEncrypted::Box.new 6 | plaintext = "hello" * 100 7 | context = {test: 123} 8 | ciphertext = client.encrypt(plaintext, context: context) 9 | assert_equal plaintext, client.decrypt(ciphertext, context: context) 10 | end 11 | 12 | def test_context_order 13 | client = KmsEncrypted::Box.new 14 | plaintext = "hello" * 100 15 | context1 = {a: 1, b: 2} 16 | context2 = {b: 2, a: 1} 17 | ciphertext = client.encrypt(plaintext, context: context1) 18 | assert_equal plaintext, client.decrypt(ciphertext, context: context2) 19 | end 20 | 21 | def test_context_proc 22 | client = KmsEncrypted::Box.new 23 | plaintext = "hello" * 100 24 | context = ->(v) { {test: 123} } 25 | ciphertext = client.encrypt(plaintext, context: context) 26 | assert_equal plaintext, client.decrypt(ciphertext, context: context) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/client_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ClientTest < Minitest::Test 4 | def test_encrypt 5 | client = KmsEncrypted::Client.new 6 | plaintext = "hello" * 100 7 | context = {test: 123} 8 | ciphertext = client.encrypt(plaintext, context: context) 9 | assert_equal plaintext, client.decrypt(ciphertext, context: context) 10 | end 11 | 12 | def test_context_order 13 | client = KmsEncrypted::Client.new 14 | plaintext = "hello" * 100 15 | context1 = {a: 1, b: 2} 16 | context2 = {b: 2, a: 1} 17 | ciphertext = client.encrypt(plaintext, context: context1) 18 | assert_equal plaintext, client.decrypt(ciphertext, context: context2) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/kms_encrypted_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class KmsEncryptedTest < Minitest::Test 4 | def setup 5 | User.delete_all 6 | create_user 7 | end 8 | 9 | def test_create 10 | user = create_user 11 | assert_equal "test@example.org", user.email 12 | assert_equal "555-555-5555", user.phone 13 | end 14 | 15 | def test_update_eager_encrypt_false 16 | user = User.create!(name: "Test") 17 | assert_operations encrypt: 0 do 18 | user.email = "test@example.org" 19 | end 20 | assert_operations encrypt: 1 do 21 | user.save! 22 | end 23 | user = User.last 24 | assert_equal "test@example.org", user.email 25 | end 26 | 27 | def test_update_eager_encrypt_try 28 | user = User.create!(name: "Test") 29 | assert_operations encrypt: 1 do 30 | user.phone = "555-555-5555" 31 | end 32 | assert_operations encrypt: 0 do 33 | user.save! 34 | end 35 | user = User.last 36 | assert_equal "555-555-5555", user.phone 37 | end 38 | 39 | def test_read 40 | user = User.last 41 | assert_equal "test@example.org", user.email 42 | assert_equal "555-555-5555", user.phone 43 | end 44 | 45 | def test_update_does_not_decrypt 46 | assert_operations encrypt: 1 do 47 | user = User.last 48 | user.encrypted_kms_key = nil 49 | user.encrypted_email = nil 50 | user.update!(email: "test@example.org") 51 | end 52 | end 53 | 54 | def test_reload_clears_data_key_cache 55 | assert_operations decrypt: 2 do 56 | user = User.last 57 | user.email 58 | user.reload 59 | user.email 60 | end 61 | end 62 | 63 | def test_rotate 64 | user = User.last 65 | fields = user.attributes 66 | user.rotate_kms_key! 67 | 68 | %w(encrypted_email encrypted_email_iv encrypted_kms_key).each do |attr| 69 | refute_equal user.send(attr), fields[attr] 70 | end 71 | 72 | user.reload 73 | assert_equal "test@example.org", user.email 74 | end 75 | 76 | def test_rotate_phone 77 | user = User.last 78 | fields = user.attributes 79 | user.rotate_kms_key_phone! 80 | 81 | %w(encrypted_phone encrypted_phone_iv encrypted_kms_key_phone).each do |attr| 82 | assert user.send(attr) != fields[attr], "#{attr} expected to change" 83 | end 84 | 85 | user.reload 86 | assert_equal "555-555-5555", user.phone 87 | end 88 | 89 | def test_kms_keys 90 | assert User.kms_keys[:kms_key] 91 | assert User.kms_keys[:kms_key_phone] 92 | end 93 | 94 | def test_inheritance 95 | assert_equal [:kms_key, :kms_key_phone, :kms_key_street, :kms_key_city], User.kms_keys.keys 96 | assert_equal [:kms_key, :kms_key_phone, :kms_key_street, :kms_key_city, :kms_key_child], ActiveUser.kms_keys.keys 97 | end 98 | 99 | def test_context_hash 100 | skip unless KmsEncrypted.key_id.start_with?("vault/") 101 | 102 | context = User.last.kms_encryption_context 103 | context_hash = KmsEncrypted.context_hash(context, path: "file") 104 | assert context_hash.start_with?("hmac-sha256:") 105 | end 106 | 107 | def test_bad_context 108 | user = User.last 109 | user.name = "updated" 110 | user.save! 111 | assert_raises(KmsEncrypted::DecryptionError) do 112 | user.email 113 | end 114 | end 115 | 116 | def test_updated_at 117 | user = User.last 118 | refute_equal user.updated_at, user.created_at 119 | end 120 | 121 | def test_eager_encrypt_try 122 | user = User.create! 123 | assert_operations encrypt: 1 do 124 | user.phone = "test@example.org" 125 | end 126 | end 127 | 128 | def test_eager_encrypt_fetch_id 129 | skip if ENV["ADAPTER"] != "postgresql" 130 | 131 | user = User.create!(city: "Test") 132 | assert_operations encrypt: 1 do 133 | user.phone = "test@example.org" 134 | end 135 | end 136 | 137 | def test_versions 138 | user1 = User.create!(street: "123 Main St") 139 | assert_start_with "v1:", user1.encrypted_kms_key_street 140 | 141 | user2 = User.create!(street: "123 Main St") 142 | assert_start_with "v1:", user2.encrypted_kms_key_street 143 | 144 | with_version(2) do 145 | user2 = User.last 146 | assert user2.street # can decrypt 147 | user2.rotate_kms_key_street! 148 | assert_start_with "v2:", user2.encrypted_kms_key_street 149 | user2.reload 150 | assert user2.street # can decrypt 151 | 152 | user1 = User.first 153 | user1.street # can decrypt 154 | end 155 | end 156 | 157 | def test_bad_version 158 | user = User.create!(street: "123 Main St") 159 | user.encrypted_kms_key_street = user.encrypted_kms_key_street.sub("v1:", "v3:") 160 | user.save! 161 | 162 | user = User.last 163 | error = assert_raises(KmsEncrypted::Error) do 164 | user.street 165 | end 166 | assert_equal "Version not active: 3", error.message 167 | end 168 | 169 | def test_lockbox 170 | user = create_user 171 | assert_equal "1970-01-01", user.date_of_birth 172 | user.reload 173 | assert_equal "1970-01-01", user.date_of_birth 174 | end 175 | 176 | def test_lockbox_rotate 177 | user = User.last 178 | fields = user.attributes 179 | user.rotate_kms_key! 180 | 181 | %w(date_of_birth_ciphertext encrypted_kms_key).each do |attr| 182 | refute_equal user.send(attr), fields[attr] 183 | end 184 | 185 | user.reload 186 | assert_equal "1970-01-01", user.date_of_birth 187 | end 188 | 189 | def test_lockbox_active_storage 190 | user = ActiveStorageUser.create! 191 | error = assert_raises(KmsEncrypted::Error) do 192 | user.rotate_kms_key! 193 | end 194 | assert_equal "Can't rotate key used for encrypted files", error.message 195 | end 196 | 197 | def test_lockbox_active_storage_different_key 198 | user = ActiveStorageAdmin.create! 199 | user.rotate_kms_key! 200 | end 201 | 202 | def test_lockbox_carrierwave 203 | user = CarrierWaveUser.create! 204 | _, stderr = capture_io do 205 | user.rotate_kms_key! 206 | end 207 | assert_match "Can't detect if encrypted uploader uses this key", stderr 208 | end 209 | 210 | def test_lockbox_carrierwave_different_key 211 | user = CarrierWaveAdmin.create! 212 | user.rotate_kms_key! 213 | end 214 | 215 | private 216 | 217 | def assert_operations(expected) 218 | $events.clear 219 | yield 220 | assert_equal expected.select { |k, v| v > 0 }, $events 221 | end 222 | 223 | def assert_start_with(start, str) 224 | assert str.start_with?(start), "Expected to start with #{start}" 225 | end 226 | 227 | def with_version(version) 228 | previous_version = $version 229 | begin 230 | $version = version 231 | yield 232 | ensure 233 | $version = previous_version 234 | end 235 | end 236 | 237 | def create_user 238 | User.create!(name: "Test", email: "test@example.org", phone: "555-555-5555", date_of_birth: "1970-01-01") 239 | end 240 | end 241 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "carrierwave" 3 | require "active_record" 4 | require "carrierwave/orm/activerecord" 5 | Bundler.require(:default) 6 | require "minitest/autorun" 7 | require "minitest/pride" 8 | 9 | # must come before vault 10 | ENV["VAULT_ADDR"] ||= "http://127.0.0.1:8200" 11 | require "vault" 12 | 13 | if ENV["ADAPTER"] == "postgresql" 14 | ActiveRecord::Base.establish_connection adapter: "postgresql", database: "kms_encrypted_test" 15 | else 16 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 17 | end 18 | 19 | KmsEncrypted.key_id ||= "insecure-test-key" 20 | 21 | logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) 22 | Aws.config[:logger] = logger 23 | ActiveRecord::Base.logger = logger 24 | ActiveSupport::LogSubscriber.logger = logger 25 | Google::Apis.logger = logger if defined?(Google::Apis) 26 | 27 | if ENV["VERBOSE"] && KmsEncrypted.key_id.start_with?("projects/") 28 | KmsEncrypted.google_client.client_options.log_http_requests = true 29 | end 30 | ActiveRecord::Migration.verbose = ENV["VERBOSE"] 31 | 32 | $events = Hash.new(0) 33 | ActiveSupport::Notifications.subscribe(/kms_encrypted/) do |name, _start, _finish, _id, _payload| 34 | $events[name.sub(".kms_encrypted", "").to_sym] += 1 35 | end 36 | 37 | ActiveRecord::Schema.define do 38 | create_table :users, force: true do |t| 39 | t.string :name 40 | 41 | # attr_encrypted 42 | t.text :encrypted_email 43 | t.text :encrypted_email_iv 44 | t.text :encrypted_phone 45 | t.text :encrypted_phone_iv 46 | t.text :encrypted_street 47 | t.text :encrypted_street_iv 48 | 49 | # lockbox 50 | t.text :date_of_birth_ciphertext 51 | t.text :city_ciphertext 52 | 53 | # kms_encrypted 54 | t.text :encrypted_kms_key 55 | t.text :encrypted_kms_key_phone 56 | t.text :encrypted_kms_key_street 57 | t.text :encrypted_kms_key_city 58 | 59 | t.timestamps null: false 60 | end 61 | 62 | create_table :active_storage_users, force: true do |t| 63 | t.text :encrypted_kms_key 64 | end 65 | 66 | create_table :active_storage_admins, force: true do |t| 67 | t.text :encrypted_kms_key 68 | end 69 | 70 | create_table :carrier_wave_users, force: true do |t| 71 | t.string :license 72 | t.text :encrypted_kms_key 73 | end 74 | 75 | create_table :carrier_wave_admins, force: true do |t| 76 | t.string :document 77 | t.text :encrypted_kms_key 78 | end 79 | end 80 | 81 | $version = 1 82 | 83 | class User < ActiveRecord::Base 84 | has_kms_key 85 | has_kms_key name: :phone, eager_encrypt: :try, key_id: -> { KmsEncrypted.key_id } 86 | has_kms_key name: :street, version: -> { $version }, 87 | previous_versions: { 88 | 1 => {key_id: "insecure-test-key"} 89 | } 90 | has_kms_key name: :city, eager_encrypt: :fetch_id 91 | 92 | attr_encrypted :email, key: :kms_key 93 | attr_encrypted :phone, key: :kms_key_phone 94 | attr_encrypted :street, key: :kms_key_street 95 | 96 | has_encrypted :date_of_birth, key: :kms_key 97 | has_encrypted :city, key: :kms_key_city 98 | 99 | def kms_encryption_context 100 | {"Name" => name} 101 | end 102 | 103 | def kms_encryption_context_street(version:) 104 | {version: version} 105 | end 106 | end 107 | 108 | # ensure has_kms_key does not cause model schema to load 109 | raise "has_kms_key loading model schema early" if User.send(:schema_loaded?) 110 | 111 | class ActiveUser < User 112 | has_kms_key name: :child 113 | end 114 | 115 | class ActiveStorageUser < ActiveRecord::Base 116 | has_kms_key 117 | encrypts_attached :license, key: :kms_key 118 | end 119 | 120 | class ActiveStorageAdmin < ActiveRecord::Base 121 | has_kms_key 122 | encrypts_attached :license 123 | end 124 | 125 | CarrierWave.configure do |config| 126 | config.storage = :file 127 | config.store_dir = "/tmp/store" 128 | config.cache_dir = "/tmp/cache" 129 | end 130 | 131 | class LicenseUploader < CarrierWave::Uploader::Base 132 | encrypt key: -> { model.kms_key } 133 | end 134 | 135 | class DocumentUploader < CarrierWave::Uploader::Base 136 | encrypt 137 | end 138 | 139 | class CarrierWaveUser < ActiveRecord::Base 140 | has_kms_key 141 | mount_uploader :license, LicenseUploader 142 | end 143 | 144 | class CarrierWaveAdmin < ActiveRecord::Base 145 | has_kms_key 146 | mount_uploader :document, DocumentUploader 147 | end 148 | --------------------------------------------------------------------------------