diff --git a/Gemfile b/Gemfile index 652ab35..d628766 100644 --- a/Gemfile +++ b/Gemfile @@ -6,8 +6,9 @@ source "http://rubygems.org" # Add dependencies to develop your gem here. # Include everything needed to run rake, tests, features, etc. group :development do + gem "bundler" + gem "jeweler" gem "shoulda" - gem "bundler" - gem "jeweler" gem "simplecov" + gem "test-unit" end diff --git a/README.rdoc b/README.rdoc index 1451d53..3856bbe 100644 --- a/README.rdoc +++ b/README.rdoc @@ -8,24 +8,23 @@ Usage: # Generate a random key key = AES.key - => "290c3c5d812a4ba7ce33adf09598a462" + => "3a45d76fa5f0bd21f8dfd3f4e98fc4effe59e503857618cf58a11a31a276177b" # Encrypt a string. Default output is base_64 encoded, init_vector and cipher_text are joined with "$" b64 = AES.encrypt("A super secret message", key) - => "IJjbgbv/OvPIAf4R5qAWyg==$fy0v7JwRX4kyAWflgouQlt9XGmiDKvbQMRHmQ+vy1fA=" + => "IuTX8xX2XrrTVsq6SwWtFw==$+A8IiuUgPE+nRFVinA0vd5LUInd3QM3rGViNJIq3mx4=" # Same as above but minus the base64 encoding, init_vector and cipher_text are shoved into an array plain = AES.encrypt("A super secret message", key, {:format => :plain}) # - => [";\202\222\306\376<\206\343\023\245\312\225\214KAm", - "C\343\023\323U~W>\023y\217\341\201\371\352\334\311^\307\352{\020 H(DVw\3224N\223"] + => ["xF.^\x80ML'6\x10F2Z\xF8\x9Ep", "r\xA8P\xEBP\x10_\xC1\xD3B\xC3\xAB\xB3_qy\x91Q\x8C~\x1Ec\x9FD'\x8D\xB8\xBF\xB0\xA6\a\x9A"] # Generate a random initialization vector iv = AES.iv(:base_64) - => "IJjbgbv/OvPIAf4R5qAWyg==" + => "G8iY1e3Yvf6OqFAD4w2ZVw==" # Encrypt a string, with a provided key and init_vector. b64_iv = AES.encrypt("A super secret message", key, {:iv => iv}) - => "IJjbgbv/OvPIAf4R5qAWyg==$fy0v7JwRX4kyAWflgouQlt9XGmiDKvbQMRHmQ+vy1fA=" + => "G8iY1e3Yvf6OqFAD4w2ZVw==$B68lzBMXyOLXBSbPg4BgLBQRf68xVmS2oK4r2Xb+tXc=" AES.decrypt(b64, key) => "A super secret message" @@ -40,7 +39,7 @@ Usage: # the following example the message is exactly 16 bytes long, so no # error aries. msg = AES.encrypt("A secret message", key, {:padding => false}) - => "SnD+WIfEfjZRrl+WAM/9pw==$89sGGZsu973j8Gl6aXC8Uw==" + => "3orl0zsORP06B8+eyM+XsQ==$bqsvFPjTOzccKA/N3IEnug==" # Be sure to pass the same padding option when decrypting the # message, as it will fail if you try to decrypt unpadded data and diff --git a/Rakefile b/Rakefile index 06486db..bc7fee2 100644 --- a/Rakefile +++ b/Rakefile @@ -1,23 +1,23 @@ -require 'rubygems' -require 'bundler' +require "rubygems" +require "bundler" begin Bundler.setup(:default, :development) rescue Bundler::BundlerError => e - $stderr.puts e.message - $stderr.puts "Run `bundle install` to install missing gems" + warn e.message + warn "Run `bundle install` to install missing gems" exit e.status_code end -require 'rake' +require "rake" -require 'jeweler' +require "jeweler" Jeweler::Tasks.new do |gem| # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options gem.name = "aes" gem.homepage = "http://github.com/chicks/aes" gem.license = "MIT" - gem.summary = %Q{AES#encrypt(key, data), AES#decrypt(key, data). Capiche?} - gem.description = %Q{An AES encrypt/decrypt gem built ontop of OpenSSL. Not as quick as FastAES, but it doesn't require building - native extensions and supports Base64 encoded input and output.} + gem.summary = %{AES#encrypt(key, data), AES#decrypt(key, data). Capiche?} + gem.description = %(An AES encrypt/decrypt gem built ontop of OpenSSL. Not as quick as FastAES, but it doesn't require building + native extensions and supports Base64 encoded input and output.) gem.email = "carl.hicks@gmail.com" gem.authors = ["Carl Hicks"] # Include your dependencies below. Runtime dependencies are required when using your gem, @@ -27,21 +27,21 @@ Jeweler::Tasks.new do |gem| end Jeweler::RubygemsDotOrgTasks.new -require 'rake/testtask' +require "rake/testtask" Rake::TestTask.new(:test) do |test| - test.libs << 'lib' << 'test' - test.pattern = 'test/**/test_*.rb' + test.libs << "lib" << "test" + test.pattern = "test/**/test_*.rb" test.verbose = true end -task :default => :test +task default: :test -require 'rdoc/task' +require "rdoc/task" Rake::RDocTask.new do |rdoc| - version = File.exist?('VERSION') ? File.read('VERSION') : "" + version = File.exist?("VERSION") ? File.read("VERSION") : "" - rdoc.rdoc_dir = 'rdoc' + rdoc.rdoc_dir = "rdoc" rdoc.title = "aes #{version}" - rdoc.rdoc_files.include('README*') - rdoc.rdoc_files.include('lib/**/*.rb') + rdoc.rdoc_files.include("README*") + rdoc.rdoc_files.include("lib/**/*.rb") end diff --git a/aes.gemspec b/aes.gemspec index 1fd4ac3..77b073c 100644 --- a/aes.gemspec +++ b/aes.gemspec @@ -10,7 +10,7 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.authors = ["Carl Hicks"] s.date = "2012-05-01" - s.description = "An AES encrypt/decrypt gem built ontop of OpenSSL. Not as quick as FastAES, but it doesn't require building\n native extensions and supports Base64 encoded input and output." + s.description = "An AES encrypt/decrypt gem built ontop of OpenSSL. Not as quick as FastAES, but it doesn't require building\n native extensions and supports Base64 encoded input and output." s.email = "carl.hicks@gmail.com" s.extra_rdoc_files = [ "LICENSE.txt", @@ -35,25 +35,24 @@ Gem::Specification.new do |s| s.rubygems_version = "1.8.12" s.summary = "AES#encrypt(key, data), AES#decrypt(key, data). Capiche?" - if s.respond_to? :specification_version then + if s.respond_to? :specification_version s.specification_version = 3 - if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then - s.add_development_dependency(%q, [">= 0"]) - s.add_development_dependency(%q, [">= 0"]) - s.add_development_dependency(%q, [">= 0"]) - s.add_development_dependency(%q, [">= 0"]) + if Gem::Version.new(Gem::VERSION) >= Gem::Version.new("1.2.0") + s.add_development_dependency("bundler", [">= 0"]) + s.add_development_dependency("jeweler", [">= 0"]) + s.add_development_dependency("shoulda", [">= 0"]) + s.add_development_dependency("simplecov", [">= 0"]) else - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) + s.add_dependency("bundler", [">= 0"]) + s.add_dependency("jeweler", [">= 0"]) + s.add_dependency("shoulda", [">= 0"]) + s.add_dependency("simplecov", [">= 0"]) end else - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) - s.add_dependency(%q, [">= 0"]) + s.add_dependency("shoulda", [">= 0"]) + s.add_dependency("shoulda", [">= 0"]) + s.add_dependency("shoulda", [">= 0"]) + s.add_dependency("simplecov", [">= 0"]) end end - diff --git a/lib/aes.rb b/lib/aes.rb index 2bbeb01..92b3607 100644 --- a/lib/aes.rb +++ b/lib/aes.rb @@ -1,3 +1,3 @@ -require 'openssl' -require 'base64' -require 'aes/aes' +require "openssl" +require "base64" +require "aes/aes" diff --git a/lib/aes/aes.rb b/lib/aes/aes.rb index 0c09c89..4fbc221 100644 --- a/lib/aes/aes.rb +++ b/lib/aes/aes.rb @@ -1,17 +1,23 @@ module AES + DEFAULT_AES_CIPHER = "AES-256-CBC".freeze + class << self # Encrypts the plain_text with the provided key - def encrypt(plain_text, key, opts={}) + def encrypt(plain_text, key, opts = {}) ::AES::AES.new(key, opts).encrypt(plain_text) end + # Decrypts the cipher_text with the provided key - def decrypt(cipher_text, key, opts={}) + def decrypt(cipher_text, key, opts = {}) ::AES::AES.new(key, opts).decrypt(cipher_text) end + # Generates a random key of the specified length in bits # Default format is :plain - def key(length=256,format=:plain) - key = ::AES::AES.new("").random_key(length) + def key(length = 256, format = :plain) + bytes = OpenSSL::Random.random_bytes(length / 8) + key = bytes.unpack("H*")[0] + case format when :base_64 Base64.encode64(key).chomp @@ -19,140 +25,139 @@ def key(length=256,format=:plain) key end end - # Generates a random iv + + # Generates a random iv # Default format is :plain - def iv(format=:plain) - iv = ::AES::AES.new("").random_iv + def iv(format = :plain) + cipher = OpenSSL::Cipher.new(DEFAULT_AES_CIPHER) + cipher.encrypt + iv = cipher.random_iv + case format when :base_64 Base64.encode64(iv).chomp else iv - end + end end end - + class AES - attr :options - attr :key - attr :iv - attr :cipher - attr :cipher_text - attr :plain_text - - def initialize(key, opts={}) + attr_reader :options + attr_reader :key + attr_reader :iv + attr_reader :cipher + attr_reader :cipher_text + attr_reader :plain_text + + def initialize(key, opts = {}) merge_options opts + unless key =~ /\A[A-F0-9]{64}\z/i + raise ArgumentError, "AES Key must be a 64 character hex string" + end @cipher = nil @key = key - @iv ||= random_iv + @iv ||= ::AES.iv self end - + # Encrypts def encrypt(plain_text) @plain_text = plain_text _setup(:encrypt) - @cipher.iv = @iv - case @options[:format] - when :base_64 - @cipher_text = b64_e(@iv) << "$" << b64_e(_encrypt) - else - @cipher_text = [@iv, _encrypt] - end + @cipher.iv = @iv + @cipher_text = case @options[:format] + when :base_64 + b64_e(@iv) << "$" << b64_e(_encrypt) + else + [@iv, _encrypt] + end @cipher_text end - # Decrypts + # Decrypts def decrypt(cipher_text) @cipher_text = cipher_text _setup(:decrypt) - case @options[:format] - when :base_64 - ctext = b64_d(@cipher_text) - else - ctext = @cipher_text - end + ctext = case @options[:format] + when :base_64 + b64_d(@cipher_text) + else + @cipher_text + end @cipher.iv = ctext[0] - @plain_text = @cipher.update(ctext[1]) + @cipher.final + @plain_text = @cipher.update(ctext[1]) + @cipher.final end # Generate a random initialization vector + # There's no reason to call random_iv on an instance + # This has been left for backwards compatibility, + # but it now just delegates to the class method def random_iv - _setup(:encrypt) - @cipher.random_iv + ::AES.iv(@options[:format]) end - + # Generate a random key - def random_key(length=256) - _random_seed.unpack('H*')[0][0..((length/8)-1)] + def random_key(length = 256) + ::AES.key(length) end - + private - - # Generates a random seed value - def _random_seed(size=32) - if defined? OpenSSL::Random - return OpenSSL::Random.random_bytes(size) - else - chars = ("a".."z").to_a + ("A".."Z").to_a + ("0".."9").to_a - (1..size).collect{|a| chars[rand(chars.size)] }.join - end - end - - # Un-Base64's the IV and CipherText - # Returns an array containing the IV, and CipherText - def b64_d(data) - iv_and_ctext = [] - data.split('$').each do |part| - iv_and_ctext << Base64.decode64(part) - end - iv_and_ctext - end - - # Base64 Encodes a string - def b64_e(data) - Base64.encode64(data).chomp - end - - # Encrypts @plain_text - def _encrypt - @cipher.update(@plain_text) + @cipher.final - end - # Merge init options with defaults - def merge_options(opts) - @options = { - :format => :base_64, - :cipher => "AES-256-CBC", - :iv => nil, - :padding => true, # use cipher padding by default - }.merge! opts - _handle_iv - _handle_padding - end - - def _handle_iv - @iv = @options[:iv] - return if @iv.nil? - - case @options[:format] - when :base_64 - @iv = Base64.decode64(@options[:iv]) - end - end - - def _handle_padding - # convert value to what OpenSSL module format expects - @options[:padding] = @options[:padding] ? 1 : 0 + # Un-Base64's the IV and CipherText + # Returns an array containing the IV, and CipherText + def b64_d(data) + iv_and_ctext = [] + data.split("$").each do |part| + iv_and_ctext << Base64.decode64(part) end - - # Create a new cipher using the cipher type specified - def _setup(action) - @cipher ||= OpenSSL::Cipher::Cipher.new(@options[:cipher]) - # Toggles encryption mode - @cipher.send(action) - @cipher.padding = @options[:padding] - @cipher.key = @key.unpack('a2'*32).map{|x| x.hex}.pack('c'*32) + iv_and_ctext + end + + # Base64 Encodes a string + def b64_e(data) + Base64.encode64(data).chomp + end + + # Encrypts @plain_text + def _encrypt + @cipher.update(@plain_text) + @cipher.final + end + + # Merge init options with defaults + def merge_options(opts) + @options = { + format: :base_64, + cipher: ::AES::DEFAULT_AES_CIPHER, + iv: nil, + padding: true, # use cipher padding by default + }.merge! opts + _handle_iv + _handle_padding + end + + def _handle_iv + @iv = @options[:iv] + return if @iv.nil? + + case @options[:format] + when :base_64 + @iv = Base64.decode64(@options[:iv]) end + end + + def _handle_padding + # convert value to what OpenSSL module format expects + @options[:padding] = @options[:padding] ? 1 : 0 + end + + # Create a new cipher using the cipher type specified + def _setup(action) + @cipher ||= OpenSSL::Cipher.new(@options[:cipher]) + # Toggles encryption mode + @cipher.send(action) + @cipher.padding = @options[:padding] + @cipher.key = [@key].pack("H*") + end end end diff --git a/test/helper.rb b/test/helper.rb index 680d151..4f412fe 100644 --- a/test/helper.rb +++ b/test/helper.rb @@ -1,18 +1,18 @@ -require 'rubygems' -require 'bundler' +require "rubygems" +require "bundler" begin Bundler.setup(:default, :development) rescue Bundler::BundlerError => e - $stderr.puts e.message - $stderr.puts "Run `bundle install` to install missing gems" + warn e.message + warn "Run `bundle install` to install missing gems" exit e.status_code end -require 'test/unit' -require 'shoulda' +require "test/unit" +require "shoulda" -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib")) $LOAD_PATH.unshift(File.dirname(__FILE__)) -require 'aes' +require "aes" class Test::Unit::TestCase end diff --git a/test/test_aes.rb b/test/test_aes.rb index 527d0a2..74c5c43 100644 --- a/test/test_aes.rb +++ b/test/test_aes.rb @@ -1,39 +1,48 @@ -require 'helper' +require "helper" class TestAES < Test::Unit::TestCase - + test_key = "66c2454a167226f132098cfeb18eac31ec2a66104fc3c2187816baaba2d8ed39" + should "encrypt and decrypt a string" do - key = "01234567890123456789012345678901" + key = test_key msg = "This is a message that nobody should ever see" enc = AES.encrypt(msg, key) assert_equal msg, AES.decrypt(enc, key) - enc = AES.encrypt(msg, key, {:format => :plain}) - assert_equal msg, AES.decrypt(enc, key, {:format => :plain}) + enc = AES.encrypt(msg, key, format: :plain) + assert_equal msg, AES.decrypt(enc, key, format: :plain) end - + should "produce the same encrypted string when provided an identical key and iv" do - key = "01234567890123456789012345678901" + key = test_key msg = "This is a message that nobody should ever see" iv = AES.iv(:base_64) - enc1 = AES.encrypt(msg, key, {:iv => iv}) - enc2 = AES.encrypt(msg, key, {:iv => iv}) + enc1 = AES.encrypt(msg, key, iv: iv) + enc2 = AES.encrypt(msg, key, iv: iv) assert_equal enc1, enc2 end - + should "handle padding option" do - key = "01234567890123456789012345678901" + key = test_key msg = "This is a message that nobody should ever see" # unpadded message length should be a multiple of cipher block # length (16 bytes) - msg += " "*(16 - (msg.length % 16)) + msg += " " * (16 - (msg.length % 16)) - enc = AES.encrypt(msg, key, {:padding => false}) - assert_equal msg, AES.decrypt(enc, key, {:padding => false}) + enc = AES.encrypt(msg, key, padding: false) + assert_equal msg, AES.decrypt(enc, key, padding: false) end - + should "generate a new key when AES#key" do - assert_equal 32, AES.key.length - assert_equal 44, AES.key(256, :base_64).length + assert_equal 64, AES.key.length + assert_equal 89, AES.key(256, :base_64).length + end + + should "not accept a non-hex key" do + key = "NotHexEvenThoughItsSixtyFourCharactersLongSoAnErrorIsExpectedNow" + msg = "This is the plaintext message" + + assert_raise ArgumentError do + AES.encrypt(msg, key) + end end - end