diff --git a/README.md b/README.md index c82fc78..02de338 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 initialize 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. @@ -156,7 +156,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 a1592f6..9684378 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.1' gem.add_runtime_dependency 'activesupport', '>= 4.2', '< 5.1' - 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 17a6033..f47e9ab 100644 --- a/lib/crypt_keeper.rb +++ b/lib/crypt_keeper.rb @@ -3,7 +3,7 @@ require 'crypt_keeper/version' require 'crypt_keeper/model' require 'crypt_keeper/helper' -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 04cd17a..3a46bcb 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 d0beccf..c1e38c9 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..9faaecd --- /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 + 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) + 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 0dc7f7e..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 - 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 5d71107..3cc4380 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 # # 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 438ca23..4a88649 100644 --- a/lib/crypt_keeper/provider/postgres_pgp.rb +++ b/lib/crypt_keeper/provider/postgres_pgp.rb @@ -12,7 +12,7 @@ class PostgresPgp # # 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 cf93506..4c4428c 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 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 eac2806..79779da 100644 --- a/spec/crypt_keeper/model_spec.rb +++ b/spec/crypt_keeper/model_spec.rb @@ -109,7 +109,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ø') @@ -126,7 +126,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 @@ -135,14 +135,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 @@ -157,7 +157,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