diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c111b33 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.gem diff --git a/Changelog b/Changelog index 175ce63..ba0265a 100644 --- a/Changelog +++ b/Changelog @@ -1,2 +1,12 @@ -3-25-2009 +== 3-26-2009 + +* Changed how encrypted data is saved, now put on separate column in the same table as encrypted model +* Added tests +* Can configure name of encrypted column +* Added documentation +* Log warning if default sample keys are being used while in production mode + + +== 3-25-2009 + * Initial build diff --git a/README.rdoc b/README.rdoc index ad3aa20..bd6d23f 100644 --- a/README.rdoc +++ b/README.rdoc @@ -10,19 +10,33 @@ Run this if the gem isn't installed already Or place in your environment.rb config.gem 'ssoper-acts_as_encryptable', :source => 'http://gems.github.com' - -After installing be sure to generate the migration for the EncryptedChunk model - ./script/generate migration acts_as_encryptable_migration -== Usage +== Configuration -In your model +After installing be sure to add a column to any tables that need to be encrypted + class AddEncryptionFieldsToCreditCards < ActiveRecord::Migration + def self.up + add_column :credit_cards, :encrypted, :text + end + + def self.down + remove_column :credit_cards, :encrypted + end + end + +Or you can use the migration helper to generate the migration for the model + ./script/generate acts_as_encryptable_migration credit_cards + +And in your model class CreditCard < ActiveRecord::Base acts_as_encryptable :first_name, :last_name, :number attr_accessor :first_name, :last_name, :number end + +== Usage + Encrypt your data card = CreditCard.new card.first_name = 'Test' @@ -35,3 +49,13 @@ Decrypt your data card.decrypt! => { :first_name => 'Test', :last_name => 'User', :number => '1234567890' } + +== Tests + +Run 'rake test' + + +== Acknowledgements + +* Tobias Lütke for his blog post on asymmetric encryption using Ruby's native SSL libraries +* Paul Barry for helping me to simplify the functionality diff --git a/Rakefile b/Rakefile index d30fff6..a7861a4 100644 --- a/Rakefile +++ b/Rakefile @@ -1,7 +1,10 @@ +require 'rake' +require 'rake/testtask' + namespace :gem do task :default => :build - + desc 'Build the acts_as_encryptable gem' task :build do Dir['*.gem'].each do |gem_filename| @@ -9,13 +12,25 @@ namespace :gem do end sh "gem build acts_as_encryptable.gemspec" end - + desc 'Install the acts_as_encryptable gem' task :install do gem_filename = Dir['*.gem'].first sh "sudo gem install --local #{gem_filename}" end - + end -task :default => ['gem:build', 'gem:install'] \ No newline at end of file +task :default => ['gem:build', 'gem:install'] + +namespace :test do + Rake::TestTask.new(:unit) do |t| + t.libs << 'test' + t.pattern = 'test/unit/*_test.rb' + t.verbose = true + end +end + +task :test do + Rake::Task['test:unit'].invoke +end \ No newline at end of file diff --git a/TODO b/TODO deleted file mode 100644 index 515e64d..0000000 --- a/TODO +++ /dev/null @@ -1,6 +0,0 @@ -* Tests -* Add to configuration options -* More tests -* Documentation -* Sample usage -* Add a log warning if the user is using the sample keys provided while in production mode \ No newline at end of file diff --git a/acts_as_encryptable.gemspec b/acts_as_encryptable.gemspec index 61d3d35..71bda27 100644 --- a/acts_as_encryptable.gemspec +++ b/acts_as_encryptable.gemspec @@ -1,20 +1,18 @@ Gem::Specification.new do |s| s.name = "acts_as_encryptable" - s.version = "1.0.0" - s.date = "2009-03-25" + s.version = "1.0.1" + s.date = "2009-03-26" s.author = "Sean Soper" s.email = "sean.soper@gmail.com" - s.summary = "Encrypt and decrypt your important data" - s.description = "Encrypt your data into chunks stored in the database and attached to your model via a polymorphic relationship" - s.homepage = "http://github.com" + s.summary = "Encrypt and decrypt your data with ease" + s.description = "Encrypt and decrypt your data using 1024-bit asymmetric RSA keys" + s.homepage = "http://github.com/ssoper/acts_as_encryptable" s.require_path = "lib" s.files = %w{ acts_as_encryptable.gemspec lib/acts_as_encryptable.rb lib/acts_as_encryptable/base.rb lib/acts_as_encryptable/crypto.rb - lib/acts_as_encryptable/encrypted_chunk.rb generators/acts_as_encryptable_migration/acts_as_encryptable_migration_generator.rb - generators/acts_as_encryptable_migration/templates/migration.rb sample_keys/rsa_key.pub sample_keys/rsa_key MIT-LICENSE diff --git a/generators/acts_as_encryptable_migration/acts_as_encryptable_migration_generator.rb b/generators/acts_as_encryptable_migration/acts_as_encryptable_migration_generator.rb index 5d6d21d..b10d53f 100644 --- a/generators/acts_as_encryptable_migration/acts_as_encryptable_migration_generator.rb +++ b/generators/acts_as_encryptable_migration/acts_as_encryptable_migration_generator.rb @@ -1,7 +1,25 @@ -class ActsAsEncryptableMigrationGenerator < Rails::Generator::Base - def manifest +class ActsAsEncryptableMigrationGenerator < Rails::Generator::NamedBase + def manifest record do |m| - m.migration_template 'migration.rb', 'db/migrate', :migration_file_name => "acts_as_encryptable_migration" + m.migration_template 'migration:migration.rb', 'db/migrate', { + :assigns => migration_local_assigns, + :migration_file_name => "add_encryption_field_to_#{model_name}" + } + end + end + + private + + def model_name + return ARGV.first + end + + def migration_local_assigns + returning(assigns = {}) do + assigns[:migration_action] = "add" + assigns[:class_name] = "add_encryption_field_to_#{model_name}" + assigns[:table_name] = model_name + assigns[:attributes] = [Rails::Generator::GeneratedAttribute.new("encrypted", "text")] end end end diff --git a/lib/acts_as_encryptable.rb b/lib/acts_as_encryptable.rb index 89f34d5..83919b2 100644 --- a/lib/acts_as_encryptable.rb +++ b/lib/acts_as_encryptable.rb @@ -1,3 +1,2 @@ require 'acts_as_encryptable/base' require 'acts_as_encryptable/crypto' -require 'acts_as_encryptable/encrypted_chunk' diff --git a/lib/acts_as_encryptable/base.rb b/lib/acts_as_encryptable/base.rb index 068d65a..19a8c17 100644 --- a/lib/acts_as_encryptable/base.rb +++ b/lib/acts_as_encryptable/base.rb @@ -1,5 +1,9 @@ module ActsAsEncryptable module Base + WARNING_MSG = "\n \e[0m\e[1;36m[\e[37mActsAsEncryptable\e[36m] \e[1;31mUsing the provided sample keys in production mode is highly discouraged. Generate your own RSA keys using the provided crypto libraries.\e[0m\n\n" + MAX_CHUNK_SIZE = 118 + ENCRYPTED_CHUNK_SIZE = 175 + def self.included(base) base.extend(ClassMethods) end @@ -10,12 +14,21 @@ def acts_as_encryptable(*args) has_many :encrypted_chunks, :as => :encryptable before_save :encrypt! + sample_key_public = File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key.pub') + sample_key_private = File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key') + options = { - :public_key => File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key.pub'), - :private_key => File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key') + :public_key => sample_key_public, + :private_key => sample_key_private, + :column => :encrypted } options.merge!(args.pop) if args.last.is_a? Hash + if (sample_key_public == options[:public_key]) and + (ENV['RAILS_ENV'] && ENV['RAILS_ENV'] == 'production') + RAILS_DEFAULT_LOGGER.warn WARNING_MSG + end + write_inheritable_attribute(:encrypted_fields, args.uniq) class_inheritable_reader :encrypted_fields @@ -25,38 +38,48 @@ def acts_as_encryptable(*args) write_inheritable_attribute(:private_key, ActsAsEncryptable::Crypto::Key.from_file(options[:private_key])) class_inheritable_reader :private_key - write_inheritable_attribute(:max_chunk_size, 115) - class_inheritable_reader :max_chunk_size + write_inheritable_attribute(:encrypted_column, options[:column]) + class_inheritable_reader :encrypted_column include ActsAsEncryptable::Base::InstanceMethods + extend ActsAsEncryptable::Base::SingletonMethods end end end - module InstanceMethods + module SingletonMethods + def chunkize(str, chunk_size) + (0..((str.length/chunk_size.to_f).ceil - 1)).each do |x| + start, stop = x * chunk_size, (x + 1) * chunk_size + start += 1 if start > 0 + yield str[start..stop] + end + end + end + + module InstanceMethods def encrypt! yaml_data = self.class.encrypted_fields.inject({}) do |result, field| result.merge!(field => instance_variable_get("@#{field}".to_sym)) end.to_yaml - chunks = [] - (0..((yaml_data.length/self.class.max_chunk_size.to_f).ceil - 1)).each do |x| - start, stop = x * self.class.max_chunk_size, (x + 1) * self.class.max_chunk_size - start += 1 if start > 0 - chunks << self.class.public_key.encrypt(yaml_data[start..stop]) + chunks = '' + self.class.chunkize(yaml_data, MAX_CHUNK_SIZE) do |chunk| + chunks << self.class.public_key.encrypt(chunk) end - self.encrypted_chunks = chunks.collect do |chunk| - EncryptedChunk.new(:data => chunk) - end + self.send("#{self.class.encrypted_column}=", chunks) end def decrypt! - yaml_data = self.encrypted_chunks.collect do |chunk| - self.class.private_key.decrypt(chunk.data) - end.join + encrypted_data = self.send("#{self.class.encrypted_column}") + + chunks = '' + self.class.chunkize(encrypted_data, ENCRYPTED_CHUNK_SIZE) do |chunk| + chunks << self.class.private_key.decrypt(chunk) + end - YAML::load(yaml_data).each do |k, v| + YAML::load(chunks).each do |k, v| instance_variable_set("@#{k}".to_sym, v) end end @@ -64,4 +87,4 @@ def decrypt! end end -ActiveRecord::Base.send(:include, ActsAsEncryptable::Base) \ No newline at end of file +ActiveRecord::Base.send(:include, ActsAsEncryptable::Base) diff --git a/lib/acts_as_encryptable/crypto.rb b/lib/acts_as_encryptable/crypto.rb index b0d8987..203e7a3 100644 --- a/lib/acts_as_encryptable/crypto.rb +++ b/lib/acts_as_encryptable/crypto.rb @@ -43,4 +43,4 @@ def key_type end end end -end \ No newline at end of file +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..33df072 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,21 @@ +require 'rubygems' +require 'test/unit' +require 'activerecord' + +ENV['RAILS_ENV'] = 'test' +require File.dirname(__FILE__) + '/../lib/acts_as_encryptable' + +class Test::Unit::TestCase + + def establish_connection(db_file = nil) + db_file = File.join(File.dirname(__FILE__), '/tmp/tests.sqlite') unless db_file + ActiveRecord::Base.configurations = { 'ActiveRecord::Base' => { :adapter => 'sqlite3', :database => db_file, :timeout => 5000 } } + ActiveRecord::Base.establish_connection('ActiveRecord::Base') + ActiveRecord::Base.connection.execute('drop table if exists credit_cards') + ActiveRecord::Base.connection.execute('create table credit_cards (id integer, encrypted text)') + ActiveRecord::Base.connection.execute('drop table if exists people') + ActiveRecord::Base.connection.execute('create table people (id integer, important_data text)') + ActiveRecord::Base.connection + end + +end \ No newline at end of file diff --git a/test/tmp/.gitignore b/test/tmp/.gitignore new file mode 100644 index 0000000..9b1dffd --- /dev/null +++ b/test/tmp/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/test/unit/base_test.rb b/test/unit/base_test.rb new file mode 100644 index 0000000..0e51560 --- /dev/null +++ b/test/unit/base_test.rb @@ -0,0 +1,69 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class BaseTest < Test::Unit::TestCase + + class CreditCard < ActiveRecord::Base + attr_accessor :name_on_card, :number, :expiration + acts_as_encryptable :name_on_card, :number, :expiration + end + + class Person < ActiveRecord::Base + attr_accessor :first_name, :last_name, :ssn + acts_as_encryptable :first_name, :last_name, :ssn, :column => 'important_data' + end + + def setup + @connection = establish_connection + end + + def test_a_credit_card + card = CreditCard.new(valid_credit_card) + assert card.save + end + + def test_data_is_encrypted + test_a_credit_card + card = CreditCard.last + assert !card.name_on_card + assert !card.number + assert !card.expiration + end + + def test_data_is_decrypted + test_a_credit_card + card = CreditCard.last + card.decrypt! + assert card.name_on_card == valid_credit_card[:name_on_card] + assert card.number == valid_credit_card[:number] + assert card.expiration == valid_credit_card[:expiration] + end + + def test_set_encrypted_column_name + person = Person.new(valid_person) + assert person.save! + person = Person.last + person.decrypt! + assert person.first_name = valid_person[:first_name] + assert person.last_name = valid_person[:last_name] + assert person.ssn = valid_person[:ssn] + end + + private + + def valid_credit_card + { + :name_on_card => 'Test User', + :number => '1234567890123456', + :expiration => (Date.today + 1.year).strftime("%i/%y") + } + end + + def valid_person + { + :first_name => 'Test', + :last_name => 'User', + :ssn => '111223333' + } + end + +end \ No newline at end of file diff --git a/test/unit/crypto_test.rb b/test/unit/crypto_test.rb new file mode 100644 index 0000000..f2deed9 --- /dev/null +++ b/test/unit/crypto_test.rb @@ -0,0 +1,24 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class CryptoTest < Test::Unit::TestCase + + def setup + @text = "I am a secret" + @public_key = File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key.pub') + @private_key = File.join(File.dirname(__FILE__), '/../../sample_keys/rsa_key') + end + + def test_encryption + public_key = ActsAsEncryptable::Crypto::Key.from_file(@public_key) + encrypted = public_key.encrypt(@text) + assert encrypted != @text + end + + def test_decryption + public_key = ActsAsEncryptable::Crypto::Key.from_file(@public_key) + private_key = ActsAsEncryptable::Crypto::Key.from_file(@private_key) + encrypted = public_key.encrypt(@text) + assert @text == private_key.decrypt(encrypted) + end + +end