diff --git a/README.md b/README.md index 2f38422..cd31bdc 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ is a simple class that does 3 things. Note: Any options defined using `crypt_keeper` will be passed to `new` as a hash. -You can see an AES example [here](https://github.com/jmazzi/crypt_keeper/blob/master/lib/crypt_keeper/provider/aes_new.rb). +You can see an example [here](https://github.com/jmazzi/crypt_keeper/blob/master/lib/crypt_keeper/provider/active_support.rb). ## Why? @@ -27,7 +27,7 @@ simple that *just works*. ```ruby class MyModel < ActiveRecord::Base - crypt_keeper :field, :other_field, :encryptor => :aes_new, :key => 'super_good_password', salt: 'salt' + crypt_keeper :field, :other_field, encryptor: :active_support, key: 'super_good_password', salt: 'salt' end model = MyModel.new(field: 'sometext') @@ -53,7 +53,7 @@ You can force an encoding on the plaintext before encryption and after decryptio ```ruby class MyModel < ActiveRecord::Base - crypt_keeper :field, :other_field, :encryptor => :aes_new, :key => 'super_good_password', salt: 'salt', :encoding => 'UTF-8' + crypt_keeper :field, :other_field, encryptor: :active_support, key: 'super_good_password', salt: 'salt', encoding: 'UTF-8' end model = MyModel.new(field: 'Tromsø') @@ -68,7 +68,7 @@ If you are working with an existing table you would like to encrypt, you must us ```ruby class MyExistingModel < ActiveRecord::Base - crypt_keeper :field, :other_field, :encryptor => :aes_new, :key => 'super_good_password', salt: 'salt' + crypt_keeper :field, :other_field, encryptor: :active_support, key: 'super_good_password', salt: 'salt' end MyExistingModel.encrypt_table! @@ -78,10 +78,10 @@ Running `encrypt_table!` will encrypt all rows in the database using the encrypt ## Supported Available Encryptors -There are four supported encryptors: `aes_new`, `mysql_aes_new`, `postgres_pgp`, `postgres_pgp_public_key`. +There are four supported encryptors: `active_support`, `mysql_aes_new`, `postgres_pgp`, `postgres_pgp_public_key`. -* [AES New](https://github.com/jmazzi/crypt_keeper/blob/master/lib/crypt_keeper/provider/aes_new.rb) - * Encryption is peformed using AES-256 via OpenSSL. +* [ActiveSupport](https://github.com/jmazzi/crypt_keeper/blob/master/lib/crypt_keeper/provider/active_support.rb) + * Encryption is performed using [ActiveSupport::MessageEncryptor](http://api.rubyonrails.org/classes/ActiveSupport/MessageEncryptor.html) * Passphrases are derived using [PBKDF2](http://en.wikipedia.org/wiki/PBKDF2) * [MySQL AES New](https://github.com/jmazzi/crypt_keeper/blob/master/lib/crypt_keeper/provider/mysql_aes_new.rb) @@ -111,8 +111,8 @@ There are four supported encryptors: `aes_new`, `mysql_aes_new`, `postgres_pgp`, ## Searching Searching ciphertext is a complex problem that varies depending on the encryption algorithm you choose. All of the bundled providers include search support, but they have some caveats. -* AES - * The Ruby implementation of AES uses a random initialization vector. The same plaintext encrypted multiple times will have different output each time for the ciphertext. Since this is the case, it is not possible to search leveraging the database. Database rows will need to be filtered in memory. It is suggested that you use a scope or ActiveRecord batches to narrow the results before seaching them. +* ActiveSupport::MessageEncryptor + * ActiveSupport's MessageEncryptor uses a random initialization vector when generating keys. The same plaintext encrypted multiple times will have different output each time for the ciphertext. Since this is the case, it is not possible to search leveraging the database. Database rows will need to be filtered in memory. It is suggested that you use a scope or ActiveRecord batches to narrow the results before seaching them. * Mysql AES * Surprisingly, MySQL's implementation of AES does not use a random initialization vector. The column containing the ciphertext can be indexed and searched quickly. @@ -157,7 +157,7 @@ as a string or an underscored symbol ```ruby class MyModel < ActiveRecord::Base - crypt_keeper :field, :other_field, :encryptor => :my_encryptor, :key => 'super_good_password' + crypt_keeper :field, :other_field, encryptor: :my_encryptor, key: 'super_good_password' end ``` diff --git a/crypt_keeper.gemspec b/crypt_keeper.gemspec index bd8e606..af34836 100644 --- a/crypt_keeper.gemspec +++ b/crypt_keeper.gemspec @@ -18,7 +18,6 @@ Gem::Specification.new do |gem| gem.add_runtime_dependency 'activerecord', '>= 4.2', '< 5.2' gem.add_runtime_dependency 'activesupport', '>= 4.2', '< 5.2' - gem.add_runtime_dependency 'aes', '~> 0.5.0' gem.add_runtime_dependency 'armor', '~> 0.0.2' gem.add_development_dependency 'rspec', '~> 3.5.0' diff --git a/lib/crypt_keeper.rb b/lib/crypt_keeper.rb index bdca203..f38fe78 100644 --- a/lib/crypt_keeper.rb +++ b/lib/crypt_keeper.rb @@ -4,7 +4,7 @@ require 'crypt_keeper/model' require 'crypt_keeper/helper' require 'crypt_keeper/provider/base' -require 'crypt_keeper/provider/aes_new' +require 'crypt_keeper/provider/active_support' require 'crypt_keeper/provider/mysql_aes_new' require 'crypt_keeper/provider/postgres_pgp' require 'crypt_keeper/provider/postgres_pgp_public_key' diff --git a/lib/crypt_keeper/helper.rb b/lib/crypt_keeper/helper.rb index b6d6d3c..4b97948 100644 --- a/lib/crypt_keeper/helper.rb +++ b/lib/crypt_keeper/helper.rb @@ -12,6 +12,7 @@ def escape_and_execute_sql(query) module DigestPassphrase def digest_passphrase(key, salt) + require "armor" raise ArgumentError.new("Missing :key") if key.blank? raise ArgumentError.new("Missing :salt") if salt.blank? ::Armor.digest(key, salt) diff --git a/lib/crypt_keeper/model.rb b/lib/crypt_keeper/model.rb index 581d945..2a1a491 100644 --- a/lib/crypt_keeper/model.rb +++ b/lib/crypt_keeper/model.rb @@ -3,7 +3,7 @@ module CryptKeeper module Model - extend ActiveSupport::Concern + extend ::ActiveSupport::Concern # Public: Ensures that each field exist and is of type text. This prevents # encrypted data from being truncated. diff --git a/lib/crypt_keeper/provider/active_support.rb b/lib/crypt_keeper/provider/active_support.rb new file mode 100644 index 0000000..ee0e4d6 --- /dev/null +++ b/lib/crypt_keeper/provider/active_support.rb @@ -0,0 +1,51 @@ +require "active_support/message_encryptor" + +module CryptKeeper + module Provider + class ActiveSupport < Base + attr_reader :encryptor + + # Public: Initializes the encryptor + # + # options - A hash, :key and :salt are required + # + # Returns nothing. + def initialize(options = {}) + key = options.fetch(:key) + salt = options.fetch(:salt) + + @encryptor = ::ActiveSupport::MessageEncryptor.new \ + ::ActiveSupport::KeyGenerator.new(key).generate_key(salt, 32) + end + + # Public: Encrypts a string + # + # value - Plaintext value + # + # Returns an encrypted string + def encrypt(value) + encryptor.encrypt_and_sign(value) + end + + # Public: Decrypts a string + # + # value - Cipher text + # + # Returns a plaintext string + def decrypt(value) + encryptor.decrypt_and_verify(value) + end + + # Public: Searches the table + # + # records - ActiveRecord::Relation + # field - Field name to match + # criteria - Value to match + # + # Returns an Enumerable + def search(records, field, criteria) + records.select { |record| record[field] == criteria } + end + end + end +end diff --git a/lib/crypt_keeper/provider/aes_new.rb b/lib/crypt_keeper/provider/aes_new.rb deleted file mode 100644 index 5b10bef..0000000 --- a/lib/crypt_keeper/provider/aes_new.rb +++ /dev/null @@ -1,53 +0,0 @@ -require 'aes' -require 'armor' - -module CryptKeeper - module Provider - class AesNew < Base - include CryptKeeper::Helper::DigestPassphrase - - # Public: The encryption key - attr_accessor :key - - # Public: Initializes the class - # - # options - A hash of options. :key and :salt are required - def initialize(options = {}) - @key = digest_passphrase(options[:key], options[:salt]) - end - - # Public: Encrypt a string - # - # Note: nil and empty strings are not encryptable with AES. - # When they are encountered, the orignal value is returned. - # Otherwise, returns the encrypted string - # - # Returns a String - def encrypt(value) - AES.encrypt(value, key) - end - - # Public: Decrypt a string - # - # Note: nil and empty strings are not encryptable with AES (and thus cannot be decrypted). - # When they are encountered, the orignal value is returned. - # Otherwise, returns the decrypted string - # - # Returns a String - def decrypt(value) - AES.decrypt(value, key) - end - - # Public: Search for a record - # - # record - An ActiveRecord collection - # field - The field to search - # criteria - A string to search with - # - # Returns an Enumerable - def search(records, field, criteria) - records.select { |record| record[field] == criteria } - end - end - end -end diff --git a/lib/crypt_keeper/provider/mysql_aes_new.rb b/lib/crypt_keeper/provider/mysql_aes_new.rb index f8e1a6b..aba3919 100644 --- a/lib/crypt_keeper/provider/mysql_aes_new.rb +++ b/lib/crypt_keeper/provider/mysql_aes_new.rb @@ -12,7 +12,7 @@ class MysqlAesNew < Base # # options - A hash, :key and :salt are required def initialize(options = {}) - ActiveSupport.run_load_hooks(:crypt_keeper_mysql_aes_log, self) + ::ActiveSupport.run_load_hooks(:crypt_keeper_mysql_aes_log, self) @key = digest_passphrase(options[:key], options[:salt]) end diff --git a/lib/crypt_keeper/provider/postgres_pgp.rb b/lib/crypt_keeper/provider/postgres_pgp.rb index c994aba..e3d67a0 100644 --- a/lib/crypt_keeper/provider/postgres_pgp.rb +++ b/lib/crypt_keeper/provider/postgres_pgp.rb @@ -13,7 +13,7 @@ class PostgresPgp < Base # # options - A hash, :key is required def initialize(options = {}) - ActiveSupport.run_load_hooks(:crypt_keeper_postgres_pgp_log, self) + ::ActiveSupport.run_load_hooks(:crypt_keeper_postgres_pgp_log, self) @key = options.fetch(:key) do raise ArgumentError, "Missing :key" diff --git a/lib/crypt_keeper/provider/postgres_pgp_public_key.rb b/lib/crypt_keeper/provider/postgres_pgp_public_key.rb index ce0d5d8..66c0917 100644 --- a/lib/crypt_keeper/provider/postgres_pgp_public_key.rb +++ b/lib/crypt_keeper/provider/postgres_pgp_public_key.rb @@ -8,7 +8,7 @@ class PostgresPgpPublicKey < Base attr_accessor :key def initialize(options = {}) - ActiveSupport.run_load_hooks(:crypt_keeper_postgres_pgp_log, self) + ::ActiveSupport.run_load_hooks(:crypt_keeper_postgres_pgp_log, self) @key = options.fetch(:key) do raise ArgumentError, "Missing :key" diff --git a/spec/crypt_keeper/model_spec.rb b/spec/crypt_keeper/model_spec.rb index e3f2597..ca690f6 100644 --- a/spec/crypt_keeper/model_spec.rb +++ b/spec/crypt_keeper/model_spec.rb @@ -120,7 +120,7 @@ end context "Encodings" do - subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :aes_new, encoding: 'utf-8' } + subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :active_support, encoding: 'utf-8' } it "forces the encoding on decrypt" do record = subject.create!(storage: 'Tromsø') @@ -137,7 +137,7 @@ end context "Initial Table Encryption" do - subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :aes_new } + subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :active_support } before do subject.delete_all @@ -146,14 +146,14 @@ end it "encrypts the table" do - expect { subject.first(5).map(&:storage) }.to raise_error(OpenSSL::Cipher::CipherError) + expect { subject.first(5).map(&:storage) }.to raise_error(ActiveSupport::MessageVerifier::InvalidSignature) subject.encrypt_table! expect { subject.first(5).map(&:storage) }.not_to raise_error end end context "Table Decryption (Reverse of Initial Table Encryption)" do - subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :aes_new } + subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :active_support } let!(:storage_entries) { 5.times.map { |i| "testing#{i}" } } before do @@ -168,7 +168,7 @@ end context "Missing Attributes" do - subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :aes_new, encoding: 'utf-8' } + subject { create_encrypted_model :storage, key: 'tool', salt: 'salt', encryptor: :active_support, encoding: 'utf-8' } it "doesn't attempt decryption of missing attributes" do subject.create!(storage: 'blah') diff --git a/spec/crypt_keeper/provider/active_support_spec.rb b/spec/crypt_keeper/provider/active_support_spec.rb new file mode 100644 index 0000000..4c800ad --- /dev/null +++ b/spec/crypt_keeper/provider/active_support_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +describe CryptKeeper::Provider::ActiveSupport do + subject { described_class.new(key: 'cake', salt: 'salt') } + + let :plaintext do + "string" + end + + let :encrypted do + subject.encrypt plaintext + end + + let :decrypted do + subject.decrypt encrypted + end + + describe "#encrypt" do + specify { expect(encrypted).to_not eq(plaintext) } + specify { expect(encrypted).to_not be_blank } + end + + describe "#decrypt" do + specify { expect(decrypted).to eq(plaintext) } + specify { expect(decrypted).to_not be_blank } + end + + describe "#search" do + let :records do + [{ name: 'Bob' }, { name: 'Tim' }] + end + + it "finds the matching record" do + expect(subject.search(records, :name, 'Bob')).to eql([records.first]) + end + end +end diff --git a/spec/crypt_keeper/provider/aes_new_spec.rb b/spec/crypt_keeper/provider/aes_new_spec.rb deleted file mode 100644 index 5b09bc4..0000000 --- a/spec/crypt_keeper/provider/aes_new_spec.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'spec_helper' - -describe CryptKeeper::Provider::AesNew do - subject { described_class.new(key: 'cake', salt: 'salt') } - - describe "#initialize" do - let(:digested_key) do - ::Armor.digest('cake', 'salt') - end - - specify { expect(subject.key).to eq(digested_key) } - specify { expect { described_class.new }.to raise_error(ArgumentError, "Missing :key") } - end - - describe "#encrypt" do - let(:encrypted) do - subject.encrypt 'string' - end - - specify { expect(encrypted).to_not eq('string') } - specify { expect(encrypted).to_not be_blank } - end - - describe "#decrypt" do - let(:decrypted) do - subject.decrypt "V02ebRU2wLk25AizasROVg==$kE+IpRaUNdBfYqR+WjMqvA==" - end - - specify { expect(decrypted).to eq('string') } - end - - describe "#search" do - let(:records) do - [{ name: 'Bob' }, { name: 'Tim' }] - end - - it "finds the matching record" do - expect(subject.search(records, :name, 'Bob')).to eql([records.first]) - end - end -end