├── .github └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── active_kms.gemspec ├── gemfiles ├── activerecord71.gemfile └── activerecord72.gemfile ├── lib ├── active_kms.rb └── active_kms │ ├── aws_key_provider.rb │ ├── base_key_provider.rb │ ├── google_cloud_key_provider.rb │ ├── log_subscriber.rb │ ├── test_key_provider.rb │ ├── vault_key_provider.rb │ └── version.rb └── test ├── key_provider_test.rb └── test_helper.rb /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | include: 9 | - ruby: 3.4 10 | gemfile: Gemfile 11 | - ruby: 3.3 12 | gemfile: gemfiles/activerecord72.gemfile 13 | - ruby: 3.2 14 | gemfile: gemfiles/activerecord71.gemfile 15 | runs-on: ubuntu-latest 16 | env: 17 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 18 | KEY_PROVIDER: vault 19 | KEY_ID: my-key 20 | VAULT_ADDR: http://127.0.0.1:8200 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: ruby/setup-ruby@v1 24 | with: 25 | ruby-version: ${{ matrix.ruby }} 26 | bundler-cache: true 27 | - run: | 28 | curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - 29 | sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" 30 | sudo apt-get update 31 | sudo apt-get install vault 32 | vault server -dev & 33 | sleep 1 34 | vault secrets enable transit 35 | vault write -f transit/keys/my-key 36 | - run: bundle exec rake test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.lock 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.2.0 (2025-05-04) 2 | 3 | - Dropped support for Ruby < 3.2 and Active Record < 7.1 4 | 5 | ## 0.1.1 (2023-12-01) 6 | 7 | - Fixed deprecation warning with Active Record 7.1 8 | 9 | ## 0.1.0 (2021-12-14) 10 | 11 | - First release 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "activerecord", "~> 8.0.0" 8 | 9 | gem "aws-sdk-kms" 10 | gem "vault" 11 | gem "nokogiri" # for aws-sdk 12 | 13 | platform :ruby do 14 | gem "sqlite3" 15 | gem "google-cloud-kms" 16 | end 17 | 18 | platform :jruby do 19 | gem "sqlite3-ffi" 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-2025 Andrew Kane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Active KMS 2 | 3 | Simple, secure key management for [Active Record encryption](https://edgeguides.rubyonrails.org/active_record_encryption.html) 4 | 5 | **Note:** At the moment, encryption requires three encryption requests and one decryption request. See [this Rails issue](https://github.com/rails/rails/issues/42388) for more info. As a result, there’s no way to grant encryption and decryption permission separately. 6 | 7 | For Lockbox and attr_encrypted, check out [KMS Encrypted](https://github.com/ankane/kms_encrypted) 8 | 9 | [![Build Status](https://github.com/ankane/active_kms/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/active_kms/actions) 10 | 11 | ## Installation 12 | 13 | Add this line to your application’s Gemfile: 14 | 15 | ```ruby 16 | gem "active_kms" 17 | ``` 18 | 19 | And follow the instructions for your key management service: 20 | 21 | - [AWS KMS](#aws-kms) 22 | - [Google Cloud KMS](#google-cloud-kms) 23 | - [Vault](#vault) 24 | 25 | ### AWS KMS 26 | 27 | Add this line to your application’s Gemfile: 28 | 29 | ```ruby 30 | gem "aws-sdk-kms" 31 | ``` 32 | 33 | 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. 34 | 35 | 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) 36 | 37 | ```sh 38 | KMS_KEY_ID=alias/my-key 39 | AWS_ACCESS_KEY_ID=... 40 | AWS_SECRET_ACCESS_KEY=... 41 | ``` 42 | 43 | And add to `config/application.rb`: 44 | 45 | ```ruby 46 | config.active_record.encryption.key_provider = ActiveKms::AwsKeyProvider.new(key_id: ENV["KMS_KEY_ID"]) 47 | ``` 48 | 49 | ### Google Cloud KMS 50 | 51 | Add this line to your application’s Gemfile: 52 | 53 | ```ruby 54 | gem "google-cloud-kms" 55 | ``` 56 | 57 | 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. 58 | 59 | 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) 60 | 61 | ```sh 62 | KMS_KEY_ID=projects/my-project/locations/global/keyRings/my-key-ring/cryptoKeys/my-key 63 | ``` 64 | 65 | And add to `config/application.rb`: 66 | 67 | ```ruby 68 | config.active_record.encryption.key_provider = ActiveKms::GoogleCloudKeyProvider.new(key_id: ENV["KMS_KEY_ID"]) 69 | ``` 70 | 71 | ### Vault 72 | 73 | Add this line to your application’s Gemfile: 74 | 75 | ```ruby 76 | gem "vault" 77 | ``` 78 | 79 | Enable the [transit](https://www.vaultproject.io/docs/secrets/transit/index.html) secrets engine 80 | 81 | ```sh 82 | vault secrets enable transit 83 | ``` 84 | 85 | And create a key 86 | 87 | ```sh 88 | vault write -f transit/keys/my-key 89 | ``` 90 | 91 | Set it in your environment along with your Vault credentials ([dotenv](https://github.com/bkeepers/dotenv) is great for this) 92 | 93 | ```sh 94 | KMS_KEY_ID=my-key 95 | VAULT_ADDR=http://127.0.0.1:8200 96 | VAULT_TOKEN=secret 97 | ``` 98 | 99 | And add to `config/application.rb`: 100 | 101 | ```ruby 102 | config.active_record.encryption.key_provider = ActiveKms::VaultKeyProvider.new(key_id: ENV["KMS_KEY_ID"]) 103 | ``` 104 | 105 | ## Per-Attribute Keys 106 | 107 | Specify per-attribute keys 108 | 109 | ```ruby 110 | class User < ApplicationRecord 111 | encrypts :email, key_provider: ActiveKms::AwsKeyProvider.new(key_id: "...") 112 | end 113 | ``` 114 | 115 | ## Testing 116 | 117 | For testing, you can prevent network calls to KMS by adding to `config/environments/test.rb`: 118 | 119 | ```ruby 120 | config.active_record.encryption.key_provider = ActiveKms::TestKeyProvider.new 121 | ``` 122 | 123 | ## Key Rotation 124 | 125 | Key management services allow you to rotate the master key without any code changes. 126 | 127 | - For AWS KMS, you can use [automatic key rotation](https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html) 128 | - For Google Cloud, use the Google Cloud Console or API 129 | - For Vault, use: 130 | 131 | ```sh 132 | vault write -f transit/keys/my-key/rotate 133 | ``` 134 | 135 | New data will be encrypted with the new master key version. To encrypt existing data with new master key version, run: 136 | 137 | ```ruby 138 | User.find_each do |user| 139 | user.encrypt 140 | end 141 | ``` 142 | 143 | ### Switching Keys 144 | 145 | You can change keys within your current KMS or move to a different KMS without downtime. 146 | 147 | Set globally in `config/application.rb`: 148 | 149 | ```ruby 150 | config.active_record.encryption.previous = [{key_provider: ActiveKms::AwsKeyProvider.new(key_id: "...")}] 151 | ``` 152 | 153 | Or per-attribute: 154 | 155 | ```ruby 156 | class User < ApplicationRecord 157 | encrypts :email, previous: [{key_provider: ActiveKms::AwsKeyProvider.new(key_id: "...")}] 158 | end 159 | ``` 160 | 161 | ## Reference 162 | 163 | Specify a client 164 | 165 | ```ruby 166 | ActiveKms::AwsKeyProvider.new(client: Aws::KMS::Client.new, ...) 167 | # or 168 | ActiveKms::GoogleCloudKeyProvider.new(client: Google::Cloud::Kms.key_management_service, ...) 169 | # or 170 | ActiveKms::VaultKeyProvider.new(client: Vault::Client.new, ...) 171 | ``` 172 | 173 | ## History 174 | 175 | View the [changelog](https://github.com/ankane/active_kms/blob/master/CHANGELOG.md) 176 | 177 | ## Contributing 178 | 179 | Everyone is encouraged to help improve this project. Here are a few ways you can help: 180 | 181 | - [Report bugs](https://github.com/ankane/active_kms/issues) 182 | - Fix bugs and [submit pull requests](https://github.com/ankane/active_kms/pulls) 183 | - Write, clarify, or fix documentation 184 | - Suggest or add new features 185 | 186 | To get started with development: 187 | 188 | ```sh 189 | git clone https://github.com/ankane/active_kms.git 190 | cd active_kms 191 | bundle install 192 | bundle exec rake test 193 | ``` 194 | -------------------------------------------------------------------------------- /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 # vault 8 | end 9 | 10 | task default: :test 11 | -------------------------------------------------------------------------------- /active_kms.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/active_kms/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "active_kms" 5 | spec.version = ActiveKms::VERSION 6 | spec.summary = "Simple, secure key management for Active Record encryption" 7 | spec.homepage = "https://github.com/ankane/active_kms" 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 "activerecord", ">= 7.1" 19 | end 20 | -------------------------------------------------------------------------------- /gemfiles/activerecord71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "sqlite3", "< 2" 8 | gem "activerecord", "~> 7.1.0" 9 | 10 | gem "aws-sdk-kms" 11 | gem "google-cloud-kms" 12 | gem "vault" 13 | gem "nokogiri" # for aws-sdk 14 | -------------------------------------------------------------------------------- /gemfiles/activerecord72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "sqlite3" 8 | gem "activerecord", "~> 7.2.0" 9 | 10 | gem "aws-sdk-kms" 11 | gem "google-cloud-kms" 12 | gem "vault" 13 | gem "nokogiri" # for aws-sdk 14 | -------------------------------------------------------------------------------- /lib/active_kms.rb: -------------------------------------------------------------------------------- 1 | # dependencies 2 | require "active_support" 3 | 4 | # modules 5 | require_relative "active_kms/base_key_provider" 6 | require_relative "active_kms/log_subscriber" 7 | require_relative "active_kms/version" 8 | 9 | # providers 10 | require_relative "active_kms/aws_key_provider" 11 | require_relative "active_kms/google_cloud_key_provider" 12 | require_relative "active_kms/test_key_provider" 13 | require_relative "active_kms/vault_key_provider" 14 | 15 | module ActiveKms 16 | class Error < StandardError; end 17 | end 18 | 19 | ActiveKms::LogSubscriber.attach_to :active_kms 20 | -------------------------------------------------------------------------------- /lib/active_kms/aws_key_provider.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class AwsKeyProvider < BaseKeyProvider 3 | private 4 | 5 | def default_client 6 | Aws::KMS::Client.new( 7 | retry_limit: 1, 8 | http_open_timeout: 2, 9 | http_read_timeout: 2 10 | ) 11 | end 12 | 13 | def encrypt(key_id, data_key) 14 | client.encrypt(key_id: key_id, plaintext: data_key).ciphertext_blob 15 | end 16 | 17 | def decrypt(_, encrypted_data_key) 18 | client.decrypt(ciphertext_blob: encrypted_data_key).plaintext 19 | end 20 | 21 | # key is stored in ciphertext so don't need to store reference 22 | # reference could be useful for multiple AWS clients 23 | # so consider an option in the future 24 | def key_id_header 25 | "aws" 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/active_kms/base_key_provider.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class BaseKeyProvider 3 | attr_reader :key_id, :client 4 | 5 | def initialize(key_id:, client: nil) 6 | @key_id = key_id 7 | @client = client || default_client 8 | end 9 | 10 | def encryption_key 11 | data_key = ActiveRecord::Encryption.key_generator.generate_random_key 12 | encrypted_data_key = 13 | ActiveSupport::Notifications.instrument("encrypt.active_kms") do 14 | encrypt(key_id, data_key) 15 | end 16 | 17 | key = ActiveRecord::Encryption::Key.new(data_key) 18 | key.public_tags.encrypted_data_key = encrypted_data_key 19 | key.public_tags.encrypted_data_key_id = key_id_header 20 | key 21 | end 22 | 23 | def decryption_keys(encrypted_message) 24 | return [] if encrypted_message.headers.encrypted_data_key_id != key_id_header 25 | 26 | encrypted_data_key = encrypted_message.headers.encrypted_data_key 27 | # rescue errors to try previous keys 28 | # rescue outside Active Support notification for more intuitive output 29 | begin 30 | data_key = 31 | ActiveSupport::Notifications.instrument("decrypt.active_kms") do 32 | decrypt(key_id, encrypted_data_key) 33 | end 34 | [ActiveRecord::Encryption::Key.new(data_key)] 35 | rescue => e 36 | warn "[active_kms] #{e.class.name}: #{e.message}" 37 | [] 38 | end 39 | end 40 | 41 | private 42 | 43 | def key_id_header 44 | @key_id_header ||= "#{prefix}/#{Digest::SHA1.hexdigest(key_id).first(4)}" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/active_kms/google_cloud_key_provider.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class GoogleCloudKeyProvider < BaseKeyProvider 3 | private 4 | 5 | def default_client 6 | require "google/cloud/kms" 7 | 8 | Google::Cloud::Kms.key_management_service do |config| 9 | config.timeout = 2 10 | end 11 | end 12 | 13 | def encrypt(key_id, data_key) 14 | client.encrypt(name: key_id, plaintext: data_key).ciphertext 15 | end 16 | 17 | def decrypt(key_id, encrypted_data_key) 18 | client.decrypt(name: key_id, ciphertext: encrypted_data_key).plaintext 19 | end 20 | 21 | def prefix 22 | "gc" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/active_kms/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class LogSubscriber < ActiveSupport::LogSubscriber 3 | def decrypt(event) 4 | return unless logger.debug? 5 | 6 | name = "Decrypt Data Key (#{event.duration.round(1)}ms)" 7 | debug " #{color(name, YELLOW, bold: true)}" 8 | end 9 | 10 | def encrypt(event) 11 | return unless logger.debug? 12 | 13 | name = "Encrypt Data Key (#{event.duration.round(1)}ms)" 14 | debug " #{color(name, YELLOW, bold: true)}" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/active_kms/test_key_provider.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class TestKeyProvider < BaseKeyProvider 3 | def initialize 4 | end 5 | 6 | private 7 | 8 | def encrypt(_, data_key) 9 | data_key 10 | end 11 | 12 | def decrypt(_, encrypted_data_key) 13 | encrypted_data_key 14 | end 15 | 16 | def key_id_header 17 | "test" 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/active_kms/vault_key_provider.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | class VaultKeyProvider < BaseKeyProvider 3 | private 4 | 5 | def default_client 6 | Vault::Client.new 7 | end 8 | 9 | def encrypt(key_id, data_key) 10 | client.logical.write("transit/encrypt/#{key_id}", plaintext: Base64.encode64(data_key)).data[:ciphertext] 11 | end 12 | 13 | def decrypt(key_id, encrypted_data_key) 14 | Base64.decode64(client.logical.write("transit/decrypt/#{key_id}", ciphertext: encrypted_data_key).data[:plaintext]) 15 | end 16 | 17 | # could store entire key_id in key_id_header but prefer reference 18 | def prefix 19 | "vt" 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/active_kms/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveKms 2 | VERSION = "0.2.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/key_provider_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class KeyProviderTest < Minitest::Test 4 | def setup 5 | User.delete_all 6 | end 7 | 8 | def test_create 9 | # should ideally be encrypt: 1 and no decrypt 10 | # https://github.com/rails/rails/issues/42388 11 | assert_operations encrypt: 1, decrypt: 1 do 12 | User.create!(email: "test@example.org") 13 | end 14 | end 15 | 16 | def test_assign 17 | user = User.new 18 | 19 | assert_operations do 20 | user.email = "test@example.org" 21 | end 22 | end 23 | 24 | def test_update 25 | user = User.create!(email: "test@example.org") 26 | 27 | # should ideally be encrypt: 1 and no decrypt 28 | # https://github.com/rails/rails/issues/42388 29 | assert_operations encrypt: 1, decrypt: 4 do 30 | user.update!(email: "test2@example.org") 31 | end 32 | end 33 | 34 | def test_query 35 | User.create!(email: "test@example.org") 36 | 37 | user = 38 | assert_operations do 39 | User.last 40 | end 41 | assert_operations decrypt: 1 do 42 | user.email 43 | end 44 | assert_operations do 45 | user.email 46 | end 47 | end 48 | 49 | private 50 | 51 | def assert_operations(**expected) 52 | $events.clear 53 | result = yield 54 | assert_equal expected.select { |k, v| v > 0 }, $events 55 | result 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | Bundler.require(:default) 3 | require "minitest/autorun" 4 | require "minitest/pride" 5 | require "active_record" 6 | 7 | ENV["VAULT_ADDR"] ||= "http://127.0.0.1:8200" 8 | 9 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 10 | 11 | logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) 12 | Aws.config[:logger] = logger 13 | ActiveRecord::Base.logger = logger 14 | ActiveSupport::LogSubscriber.logger = logger 15 | 16 | ActiveRecord::Migration.verbose = ENV["VERBOSE"] 17 | 18 | ActiveRecord::Schema.define do 19 | create_table :users do |t| 20 | t.string :email 21 | end 22 | end 23 | 24 | class User < ActiveRecord::Base 25 | encrypts :email 26 | end 27 | 28 | key_provider = 29 | case ENV["KEY_PROVIDER"] 30 | when "aws" 31 | ActiveKms::AwsKeyProvider.new(key_id: ENV.fetch("KEY_ID")) 32 | when "google" 33 | ActiveKms::GoogleCloudKeyProvider.new(key_id: ENV.fetch("KEY_ID")) 34 | when "vault" 35 | ActiveKms::VaultKeyProvider.new(key_id: ENV.fetch("KEY_ID")) 36 | when "test", nil 37 | ActiveKms::TestKeyProvider.new 38 | else 39 | raise "Invalid key provider" 40 | end 41 | 42 | ActiveRecord::Encryption.configure(key_provider: key_provider) 43 | 44 | $events = Hash.new(0) 45 | ActiveSupport::Notifications.subscribe(/active_kms/) do |name, _start, _finish, _id, _payload| 46 | $events[name.sub(".active_kms", "").to_sym] += 1 47 | end 48 | --------------------------------------------------------------------------------