diff --git a/.gitignore b/.gitignore index ec8d507..d5cd41e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ pkg/ coverage .DS_Store .ruby-version +.ruby-gemset diff --git a/URLcrypt.gemspec b/URLcrypt.gemspec new file mode 100644 index 0000000..6d2b4ff --- /dev/null +++ b/URLcrypt.gemspec @@ -0,0 +1,12 @@ +Gem::Specification.new do |s| + s.author = "Thomas Fuchs" + s.email = "thomas@slash7.com" + s.extra_rdoc_files = ["README.md"] + s.files = `git ls-files`.split("\n") + s.has_rdoc = true + s.name = 'urlcrypt' + s.require_paths << 'lib' + s.requirements << 'none' + s.summary = "Securely encode and decode short pieces of arbitrary binary data in URLs." + s.version = "0.1.1" +end diff --git a/lib/URLcrypt.rb b/lib/URLcrypt.rb index 3893cd8..262311f 100644 --- a/lib/URLcrypt.rb +++ b/lib/URLcrypt.rb @@ -1,4 +1,6 @@ require 'openssl' +require 'base64' +require 'cgi' module URLcrypt # avoid vowels to not generate four-letter words, etc. @@ -10,6 +12,10 @@ def self.key=(key) @key = key end + def self.key + @key + end + class Chunk def initialize(bytes) @bytes = bytes @@ -29,52 +35,124 @@ def encode p = n < 8 ? 5 - (@bytes.length * 8) % 5 : 0 c = @bytes.inject(0) {|m,o| (m << 8) + o} << p [(0..n-1).to_a.reverse.collect {|i| TABLE[(c >> i * 5) & 0x1f].chr}, - ("=" * (8-n))] # TODO: remove '=' padding generation + ("=" * (8-n))] # TODO: remove '=' padding generation end - end - def self.chunks(str, size) - result = [] - bytes = str.bytes - while bytes.any? do - result << Chunk.new(bytes.take(size)) - bytes = bytes.drop(size) + class BaseCoder + def initialize(options = {}) + @key = options[:key] || URLcrypt.key + @data = options[:data] + end + + # strip '=' padding, because we don't need it + def encode(d = nil) + d ||= @data + chunks(d, 5).collect(&:encode).flatten.join.tr('=','') + end + + def decode(d = nil) + d ||= @data + chunks(d, 8).collect(&:decode).flatten.join + end + + def encrypt(d = nil) + d ||= @data + crypter = cipher(:encrypt) + crypter.iv = iv = crypter.random_iv + join_parts encode(iv), encode(crypter.update(d) + crypter.final) + end + + def split_parts str + str.split('Z') + end + + def join_parts *args + args.join("Z") + end + + def decrypt(d = nil) + d ||= @data + iv, encrypted = split_parts(d).map{|part| decode(part)} + fail DecryptError, "not a valid string to decrypt" unless iv && encrypted + decrypter = cipher(:decrypt) + decrypter.iv = iv + decrypter.update(encrypted) + decrypter.final + end + + def cipher(mode) + cipher = OpenSSL::Cipher.new('aes-256-cbc') + cipher.send(mode) + cipher.key = @key + cipher + end + + def chunks(str, size) + result = [] + bytes = str.bytes + while bytes.any? do + result << Chunk.new(bytes.take(size)) + bytes = bytes.drop(size) + end + result end - result end - # strip '=' padding, because we don't need it + class Base64Coder < BaseCoder + def encode(d = nil) + d ||= @data + Base64.urlsafe_encode64(d) + end + + def decode(d = nil) + d ||= @data + Base64.urlsafe_decode64(d) + end + + def split_parts str + str.split(':') + end + + def join_parts *args + args.join(":") + end + end + + class CGIBase64Coder < BaseCoder + def encode(d = nil) + d ||= @data + CGI.escape super(d) + end + + def decode(d = nil) + d ||= @data + super CGI.unescape(d) + end + end + + def self.default_coder + @default_coder || BaseCoder + end + + def self.default_coder= val + @default_coder = val + end + def self.encode(data) - chunks(data, 5).collect(&:encode).flatten.join.tr('=','') + default_coder.new(data: data).encode end def self.decode(data) - chunks(data, 8).collect(&:decode).flatten.join + default_coder.new(data: data).decode end - + def self.decrypt(data) - iv, encrypted = data.split('Z').map{|part| decode(part)} - fail DecryptError, "not a valid string to decrypt" unless iv && encrypted - decrypter = cipher(:decrypt) - decrypter.iv = iv - decrypter.update(encrypted) + decrypter.final + default_coder.new(data: data).decrypt end - + def self.encrypt(data) - crypter = cipher(:encrypt) - crypter.iv = iv = crypter.random_iv - "#{encode(iv)}Z#{encode(crypter.update(data) + crypter.final)}" + default_coder.new(data: data).encrypt end - private - - def self.cipher(mode) - cipher = OpenSSL::Cipher.new('aes-256-cbc') - cipher.send(mode) - cipher.key = @key - cipher - end - class DecryptError < ::ArgumentError; end end diff --git a/test/URLcrypt_test.rb b/test/URLcrypt_test.rb index 0275db1..abbeb20 100644 --- a/test/URLcrypt_test.rb +++ b/test/URLcrypt_test.rb @@ -64,10 +64,53 @@ def test_encryption assert_equal(URLcrypt::decrypt(encrypted), original) end + def test_base64_encryption + # this key was generated via rake secret in a rails app, the pack() converts it into a byte array + URLcrypt::key = +['d25883a27b9a639da85ea7e159b661218799c9efa63069fac13a6778c954fb6d721968887a19bdb01af8f59eb5a90d256bd9903355c20b0b4b39bf4048b9b17b'].pack('H*') + + original = "hello world!" + + URLcrypt.default_coder = URLcrypt::Base64Coder + + encrypted = URLcrypt::encrypt(original) + assert_equal(URLcrypt::decrypt(encrypted), original) + + URLcrypt.default_coder = nil + end + + def test_cgi_base64_encryption + # this key was generated via rake secret in a rails app, the pack() converts it into a byte array + URLcrypt::key = +['d25883a27b9a639da85ea7e159b661218799c9efa63069fac13a6778c954fb6d721968887a19bdb01af8f59eb5a90d256bd9903355c20b0b4b39bf4048b9b17b'].pack('H*') + + original = "hello world!" + + URLcrypt.default_coder = URLcrypt::CGIBase64Coder + + encrypted = URLcrypt::encrypt(original) + assert_equal(URLcrypt::decrypt(encrypted), original) + + URLcrypt.default_coder = nil + end + def test_decrypt_error error = assert_raises(URLcrypt::DecryptError) do ::URLcrypt::decrypt("just some plaintext") end assert_equal error.message, "not a valid string to decrypt" end + + def test_multiple_coders + coder1 = URLcrypt::BaseCoder.new(key: [SecureRandom.hex(64)].pack("H*")) + coder2 = URLcrypt::BaseCoder.new(key: [SecureRandom.hex(64)].pack("H*")) + + str = "hello there friends." + coder1_encrypted = coder1.encrypt(str) + coder2_encrypted = coder2.encrypt(str) + + assert_not_equal(coder1_encrypted, coder2_encrypted) + assert_equal(coder1.decrypt(coder1_encrypted), coder2.decrypt(coder2_encrypted)) + end + end