From 6fbcaa3ab010b5b9929eee4f0b3f2a0aeb45dced Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Sat, 24 Oct 2015 08:33:52 -0600 Subject: [PATCH 1/6] Refactor encoding engine * Make engines swappable * Add a Base64 engine * Add a Base64Escaped engine for URLs Lets us store a lot more data in fewer characters. --- .gitignore | 1 + lib/URLcrypt.rb | 53 +++++++++++++++++++++++++++++++++++++++++++ test/URLcrypt_test.rb | 28 +++++++++++++++++++++++ 3 files changed, 82 insertions(+) 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/lib/URLcrypt.rb b/lib/URLcrypt.rb index 3893cd8..d217f8d 100644 --- a/lib/URLcrypt.rb +++ b/lib/URLcrypt.rb @@ -1,4 +1,5 @@ require 'openssl' +require 'base64' module URLcrypt # avoid vowels to not generate four-letter words, etc. @@ -6,11 +7,63 @@ module URLcrypt # filters when URLs are used in emails TABLE = "1bcd2fgh3jklmn4pqrstAvwxyz567890".freeze + def self.encoding_engine=(val) + @encoding_engine = val + end + + def self.encoding_engine + @encoding_engine || Base32Encoder + end + def self.key=(key) @key = key end class Chunk + def initialize(bytes) + @bytes = bytes + @encoder = URLcrypt.encoding_engine + end + + def decode + @encoder.new(@bytes).decode + end + + def encode + @encoder.new(@bytes).encode + end + end + + class Base64Encoder + def initialize(bytes) + @bytes = bytes + end + + def encode + Base64.encode64(@bytes).gsub("\n", "") + end + + def decode + Base64.decode64(@bytes) + end + end + + class Base64EscapedEncoder + def initialize(bytes) + @bytes = bytes + end + + def encode + CGI.escape Base64Encoder.new(@bytes).encode + end + + def decode + Base64Encoder.new(CGI.unescape(@bytes)).decode + end + end + + class Base32Encoder + def initialize(bytes) @bytes = bytes end diff --git a/test/URLcrypt_test.rb b/test/URLcrypt_test.rb index 0275db1..c2a913c 100644 --- a/test/URLcrypt_test.rb +++ b/test/URLcrypt_test.rb @@ -32,6 +32,34 @@ def assert_encode_and_decode(encoded, plain) assert_decoding(encoded, plain) end + def test_encoding_engine + assert_equal(URLcrypt.encoding_engine, URLcrypt::Base32Encoder) + end + + def test_base64_encoder + str = "some random string 1445696691.5789719" + encoded = URLcrypt::Base64Encoder.new(str).encode + assert_equal(encoded, "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ==") + end + + def test_base64_decoder + str = "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ==" + encoded = URLcrypt::Base64Encoder.new(str).decode + assert_equal(encoded, "some random string 1445696691.5789719") + end + + def test_base64_escaped_encoder + str = "some random string 1445696691.5789719" + encoded = URLcrypt::Base64EscapedEncoder.new(str).encode + assert_equal(encoded, "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ%3D%3D") + end + + def test_base64_esacped_decoder + str = "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ%3D%3D" + encoded = URLcrypt::Base64EscapedEncoder.new(str).decode + assert_equal(encoded, "some random string 1445696691.5789719") + end + def test_empty_string assert_encode_and_decode('', '') end From 1566f2e3e2b3ff606df5dd164351074d4b797d34 Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Mon, 26 Oct 2015 07:23:05 -0600 Subject: [PATCH 2/6] Revert "Refactor encoding engine" This reverts commit 6fbcaa3ab010b5b9929eee4f0b3f2a0aeb45dced. --- .gitignore | 1 - lib/URLcrypt.rb | 53 ------------------------------------------- test/URLcrypt_test.rb | 28 ----------------------- 3 files changed, 82 deletions(-) diff --git a/.gitignore b/.gitignore index d5cd41e..ec8d507 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,3 @@ pkg/ coverage .DS_Store .ruby-version -.ruby-gemset diff --git a/lib/URLcrypt.rb b/lib/URLcrypt.rb index d217f8d..3893cd8 100644 --- a/lib/URLcrypt.rb +++ b/lib/URLcrypt.rb @@ -1,5 +1,4 @@ require 'openssl' -require 'base64' module URLcrypt # avoid vowels to not generate four-letter words, etc. @@ -7,63 +6,11 @@ module URLcrypt # filters when URLs are used in emails TABLE = "1bcd2fgh3jklmn4pqrstAvwxyz567890".freeze - def self.encoding_engine=(val) - @encoding_engine = val - end - - def self.encoding_engine - @encoding_engine || Base32Encoder - end - def self.key=(key) @key = key end class Chunk - def initialize(bytes) - @bytes = bytes - @encoder = URLcrypt.encoding_engine - end - - def decode - @encoder.new(@bytes).decode - end - - def encode - @encoder.new(@bytes).encode - end - end - - class Base64Encoder - def initialize(bytes) - @bytes = bytes - end - - def encode - Base64.encode64(@bytes).gsub("\n", "") - end - - def decode - Base64.decode64(@bytes) - end - end - - class Base64EscapedEncoder - def initialize(bytes) - @bytes = bytes - end - - def encode - CGI.escape Base64Encoder.new(@bytes).encode - end - - def decode - Base64Encoder.new(CGI.unescape(@bytes)).decode - end - end - - class Base32Encoder - def initialize(bytes) @bytes = bytes end diff --git a/test/URLcrypt_test.rb b/test/URLcrypt_test.rb index c2a913c..0275db1 100644 --- a/test/URLcrypt_test.rb +++ b/test/URLcrypt_test.rb @@ -32,34 +32,6 @@ def assert_encode_and_decode(encoded, plain) assert_decoding(encoded, plain) end - def test_encoding_engine - assert_equal(URLcrypt.encoding_engine, URLcrypt::Base32Encoder) - end - - def test_base64_encoder - str = "some random string 1445696691.5789719" - encoded = URLcrypt::Base64Encoder.new(str).encode - assert_equal(encoded, "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ==") - end - - def test_base64_decoder - str = "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ==" - encoded = URLcrypt::Base64Encoder.new(str).decode - assert_equal(encoded, "some random string 1445696691.5789719") - end - - def test_base64_escaped_encoder - str = "some random string 1445696691.5789719" - encoded = URLcrypt::Base64EscapedEncoder.new(str).encode - assert_equal(encoded, "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ%3D%3D") - end - - def test_base64_esacped_decoder - str = "c29tZSByYW5kb20gc3RyaW5nIDE0NDU2OTY2OTEuNTc4OTcxOQ%3D%3D" - encoded = URLcrypt::Base64EscapedEncoder.new(str).decode - assert_equal(encoded, "some random string 1445696691.5789719") - end - def test_empty_string assert_encode_and_decode('', '') end From 700d7f569a903a914eb31734bf42faa298b41031 Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Mon, 26 Oct 2015 07:23:51 -0600 Subject: [PATCH 3/6] Add .ruby-gemset to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From cb9feee547eb3842c7401fb3eca3d62daf4eb76a Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Mon, 26 Oct 2015 07:39:49 -0600 Subject: [PATCH 4/6] Refactor to be threadsafe --- lib/URLcrypt.rb | 91 ++++++++++++++++++++++++++++--------------- test/URLcrypt_test.rb | 13 +++++++ 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/lib/URLcrypt.rb b/lib/URLcrypt.rb index 3893cd8..8bff4ed 100644 --- a/lib/URLcrypt.rb +++ b/lib/URLcrypt.rb @@ -10,6 +10,10 @@ def self.key=(key) @key = key end + def self.key + @key + end + class Chunk def initialize(bytes) @bytes = bytes @@ -29,52 +33,77 @@ 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 Coder + 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 + "#{encode(iv)}Z#{encode(crypter.update(d) + crypter.final)}" + end + + def decrypt(d = nil) + d ||= @data + iv, encrypted = d.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 + 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 def self.encode(data) - chunks(data, 5).collect(&:encode).flatten.join.tr('=','') + Coder.new(data: data).encode end def self.decode(data) - chunks(data, 8).collect(&:decode).flatten.join + 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 + 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)}" + 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..515a292 100644 --- a/test/URLcrypt_test.rb +++ b/test/URLcrypt_test.rb @@ -70,4 +70,17 @@ def test_decrypt_error end assert_equal error.message, "not a valid string to decrypt" end + + def test_multiple_coders + coder1 = URLcrypt::Coder.new(key: [SecureRandom.hex(64)].pack("H*")) + coder2 = URLcrypt::Coder.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 From e0316b2ad60df4d587afc16f3ed244c597b7c8c7 Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Mon, 26 Oct 2015 07:43:53 -0600 Subject: [PATCH 5/6] Add a gemspec so we can use the option in Gemfiles --- URLcrypt.gemspec | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 URLcrypt.gemspec 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 From 5dc4bd96514cdc39fde1e0cb9562f5f16d4de5b6 Mon Sep 17 00:00:00 2001 From: Ryan Angilly Date: Mon, 26 Oct 2015 09:42:51 -0600 Subject: [PATCH 6/6] Factor out the coding engines to make them swappable --- lib/URLcrypt.rb | 65 +++++++++++++++++++++++++++++++++++++------ test/URLcrypt_test.rb | 34 ++++++++++++++++++++-- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/lib/URLcrypt.rb b/lib/URLcrypt.rb index 8bff4ed..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. @@ -35,10 +37,9 @@ def encode [(0..n-1).to_a.reverse.collect {|i| TABLE[(c >> i * 5) & 0x1f].chr}, ("=" * (8-n))] # TODO: remove '=' padding generation end - end - class Coder + class BaseCoder def initialize(options = {}) @key = options[:key] || URLcrypt.key @data = options[:data] @@ -59,12 +60,20 @@ def encrypt(d = nil) d ||= @data crypter = cipher(:encrypt) crypter.iv = iv = crypter.random_iv - "#{encode(iv)}Z#{encode(crypter.update(d) + crypter.final)}" + 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 = d.split('Z').map{|part| decode(part)} + 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 @@ -89,20 +98,60 @@ def chunks(str, size) end end + 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) - Coder.new(data: data).encode + default_coder.new(data: data).encode end def self.decode(data) - Coder.new(data: data).decode + default_coder.new(data: data).decode end def self.decrypt(data) - Coder.new(data: data).decrypt + default_coder.new(data: data).decrypt end def self.encrypt(data) - Coder.new(data: data).encrypt + default_coder.new(data: data).encrypt end class DecryptError < ::ArgumentError; end diff --git a/test/URLcrypt_test.rb b/test/URLcrypt_test.rb index 515a292..abbeb20 100644 --- a/test/URLcrypt_test.rb +++ b/test/URLcrypt_test.rb @@ -64,6 +64,36 @@ 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") @@ -72,8 +102,8 @@ def test_decrypt_error end def test_multiple_coders - coder1 = URLcrypt::Coder.new(key: [SecureRandom.hex(64)].pack("H*")) - coder2 = URLcrypt::Coder.new(key: [SecureRandom.hex(64)].pack("H*")) + 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)