From cd5842a671b0984a42e3ca5c264d862f2abdade8 Mon Sep 17 00:00:00 2001 From: Qi He Date: Thu, 31 Mar 2016 11:17:20 -0700 Subject: [PATCH] first commit --- .gitignore | 1 + Gemfile | 11 +++++++++++ Gemfile.lock | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Guardfile | 5 +++++ README.md | 26 ++++++++++++++++++++++++++ bin/rspec | 16 ++++++++++++++++ lib/we_whisper.rb | 6 ++++++ lib/we_whisper/cipher.rb | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/we_whisper/message.rb | 40 ++++++++++++++++++++++++++++++++++++++++ lib/we_whisper/signature.rb | 11 +++++++++++ lib/we_whisper/version.rb | 3 +++ lib/we_whisper/whisper.rb | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ spec/spec_helper.rb | 3 +++ spec/we_whisper/message_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ spec/we_whisper/whisper_spec.rb | 31 +++++++++++++++++++++++++++++++ we_whisper.gemspec | 21 +++++++++++++++++++++ 16 files changed, 415 insertions(+), 0 deletions(-) create mode 100644 .gitignore create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 Guardfile create mode 100644 README.md create mode 100755 bin/rspec create mode 100644 lib/we_whisper.rb create mode 100644 lib/we_whisper/cipher.rb create mode 100644 lib/we_whisper/message.rb create mode 100644 lib/we_whisper/signature.rb create mode 100644 lib/we_whisper/version.rb create mode 100644 lib/we_whisper/whisper.rb create mode 100644 spec/spec_helper.rb create mode 100644 spec/we_whisper/message_spec.rb create mode 100644 spec/we_whisper/whisper_spec.rb create mode 100644 we_whisper.gemspec diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a9a5aec --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tmp diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..3a3c5f1 --- /dev/null +++ b/Gemfile @@ -0,0 +1,11 @@ +ruby '2.0.0' + +source 'https://rubygems.org' + +gem 'nokogiri' # parse xml, TODO: use a lighter gem + +group :development, :test do + gem 'rspec' + gem 'rspec-core' + gem 'guard-rspec', require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..8e2390e --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,65 @@ +GEM + remote: https://rubygems.org/ + specs: + coderay (1.1.1) + diff-lcs (1.2.5) + ffi (1.9.10) + formatador (0.2.5) + guard (2.13.0) + formatador (>= 0.2.4) + listen (>= 2.7, <= 4.0) + lumberjack (~> 1.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.9.12) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.6.4) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) + listen (3.0.6) + rb-fsevent (>= 0.9.3) + rb-inotify (>= 0.9.7) + lumberjack (1.0.10) + method_source (0.8.2) + mini_portile2 (2.0.0) + nenv (0.3.0) + nokogiri (1.6.7.2) + mini_portile2 (~> 2.0.0.rc2) + notiffany (0.0.8) + nenv (~> 0.1) + shellany (~> 0.0) + pry (0.10.3) + coderay (~> 1.1.0) + method_source (~> 0.8.1) + slop (~> 3.4) + rb-fsevent (0.9.7) + rb-inotify (0.9.7) + ffi (>= 0.5.0) + rspec (3.4.0) + rspec-core (~> 3.4.0) + rspec-expectations (~> 3.4.0) + rspec-mocks (~> 3.4.0) + rspec-core (3.4.4) + rspec-support (~> 3.4.0) + rspec-expectations (3.4.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.4.0) + rspec-mocks (3.4.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.4.0) + rspec-support (3.4.1) + shellany (0.0.1) + slop (3.6.0) + thor (0.19.1) + +PLATFORMS + ruby + +DEPENDENCIES + guard-rspec + nokogiri + rspec + rspec-core diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..898679d --- /dev/null +++ b/Guardfile @@ -0,0 +1,5 @@ +guard :rspec, cmd: "bin/rspec --color" do + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch('spec/spec_helper.rb') { "spec" } +end diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ebb0e5 --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +Wechat Message Encryption Wrapper +======== + +[微信加密解密技术方案](https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list&t=resource/res_list&verify=1&id=open1419318482&token=6e18ec982b3bc11a95683a6b6045cd3cf373f09d&lang=zh_CN) + +### install gem +``` +gem install 'we_whisper' + +or using bundler: +gem 'we_whisper' +``` + + +#### Decrypt message + +```Ruby +whisper.decrypt_message(encrypted_message, nonce, timestamp) +``` + + +#### Encrypt message + +```Ruby +whisper.encrypt_message(message, nonce, timestamp) +``` diff --git a/bin/rspec b/bin/rspec new file mode 100755 index 0000000..0c86b5c --- /dev/null +++ b/bin/rspec @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby +# +# This file was generated by Bundler. +# +# The application 'rspec' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", + Pathname.new(__FILE__).realpath) + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('rspec-core', 'rspec') diff --git a/lib/we_whisper.rb b/lib/we_whisper.rb new file mode 100644 index 0000000..8da3a7e --- /dev/null +++ b/lib/we_whisper.rb @@ -0,0 +1,6 @@ +module WeWhisper + require_relative 'we_whisper/cipher' + require_relative 'we_whisper/signature' + require_relative 'we_whisper/message' + require_relative 'we_whisper/whisper' +end diff --git a/lib/we_whisper/cipher.rb b/lib/we_whisper/cipher.rb new file mode 100644 index 0000000..3bc1605 --- /dev/null +++ b/lib/we_whisper/cipher.rb @@ -0,0 +1,73 @@ +# Credit: https://github.com/Eric-Guo/wechat/blob/master/lib/wechat/cipher.rb + +require 'openssl/cipher' +require 'securerandom' +require 'base64' + +module WeWhisper + module Cipher + + BLOCK_SIZE = 32 + CIPHER = 'AES-256-CBC'.freeze + + def encrypt(plain, encoding_aes_key) + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.encrypt + + cipher.padding = 0 + key_data = Base64.decode64(encoding_aes_key + '=') + cipher.key = key_data + cipher.iv = key_data[0..16] + + cipher.update(plain) + cipher.final + end + + def decrypt(msg, encoding_aes_key) + cipher = OpenSSL::Cipher.new(CIPHER) + cipher.decrypt + + cipher.padding = 0 + key_data = Base64.decode64(encoding_aes_key + '=') + cipher.key = key_data + cipher.iv = key_data[0..16] + + plain = cipher.update(msg) + cipher.final + decode_padding(plain) + end + + # app_id or corp_id + def pack(content, app_id) + random = SecureRandom.hex(8) + text = content.force_encoding('ASCII-8BIT') + msg_len = [text.length].pack('N') + + encode_padding("#{random}#{msg_len}#{text}#{app_id}") + end + + def unpack(msg) + msg = decode_padding(msg) + msg_len = msg[16, 4].reverse.unpack('V')[0] + content = msg[20, msg_len] + app_id = msg[(20 + msg_len)..-1] + + [content, app_id] + end + + private + + def encode_padding(data) + length = data.bytes.length + amount_to_pad = BLOCK_SIZE - (length % BLOCK_SIZE) + amount_to_pad = BLOCK_SIZE if amount_to_pad == 0 + padding = ([amount_to_pad].pack('c') * amount_to_pad) + data + padding + end + + def decode_padding(plain) + pad = plain.bytes[-1] + # no padding + pad = 0 if pad < 1 || pad > BLOCK_SIZE + plain[0...(plain.length - pad)] + end + end +end diff --git a/lib/we_whisper/message.rb b/lib/we_whisper/message.rb new file mode 100644 index 0000000..5e64093 --- /dev/null +++ b/lib/we_whisper/message.rb @@ -0,0 +1,40 @@ +require 'nokogiri' + +module WeWhisper + + InvalidMessageClassError = Class.new StandardError + + module Message + extend self + + def to_xml(content, signature, timestamp, nonce) +""" + + +#{timestamp} + +""" + end + + def get_value_of_key_in_message(message, key) + case message.class.name + when "String" + doc = Nokogiri::XML(message) + doc.at_xpath("//#{key}").content + when "Hash" + message[key] || message[key.to_sym] + else + raise InvalidMessageClassError, "Message can only be a String or a Hash" + end + end + + def get_encrypted_content_from_message(message) + get_value_of_key_in_message(message, "Encrypt") + end + + def get_signature_from_messge(message) + get_value_of_key_in_message(message, "MsgSignature") + end + + end +end diff --git a/lib/we_whisper/signature.rb b/lib/we_whisper/signature.rb new file mode 100644 index 0000000..a84d0dd --- /dev/null +++ b/lib/we_whisper/signature.rb @@ -0,0 +1,11 @@ +require 'digest/sha2' + +module WeWhisper + module Signature + def self.hexdigest(token, timestamp, nonce, msg_encrypt) + array = [token, timestamp, nonce] + array << msg_encrypt unless msg_encrypt.nil? + Digest::SHA1.hexdigest array.compact.collect(&:to_s).sort.join + end + end +end diff --git a/lib/we_whisper/version.rb b/lib/we_whisper/version.rb new file mode 100644 index 0000000..9e8be83 --- /dev/null +++ b/lib/we_whisper/version.rb @@ -0,0 +1,3 @@ +module WeWhisper + VERSION = '0.0.1' +end diff --git a/lib/we_whisper/whisper.rb b/lib/we_whisper/whisper.rb new file mode 100644 index 0000000..b5212b9 --- /dev/null +++ b/lib/we_whisper/whisper.rb @@ -0,0 +1,65 @@ +require "openssl" +require 'digest/sha2' +require 'base64' +require 'securerandom' +require 'nokogiri' + +require_relative 'cipher' +require_relative 'signature' +require_relative 'message' + +module WeWhisper + InvalidSignature = Class.new StandardError + AppIdNotMatch = Class.new StandardError + + class Whisper + include Cipher + + attr_reader :appid, :encoding_aes_key, :token, :options + + def initialize(appid, token, encoding_aes_key, opts={}) + @options = { + assert_signature: true, + assert_appid: true + }.merge(opts) + + @appid = appid + @token = token + @encoding_aes_key = encoding_aes_key + end + + def decrypt_message(message, nonce="", timestamp="") + # 1. Get the encrypted content from XML Message + encrypted_text = Message.get_encrypted_content_from_message(message) + + # 2. If we need to validate signature, generate one from the encrypted text + # and check with the Signature in message + if options[:assert_signature] && signature = Message.get_signature_from_messge(message) + sign = Signature.hexdigest(token, timestamp, nonce, encrypted_text) + raise InvalidSignature if sign != signature + end + + # 3. Decode and decrypt the encrypted text + decrypted_message, decrypted_appid = \ + unpack(decrypt(Base64.decode64(encrypted_text), encoding_aes_key)) + + if options[:assert_appid] + raise AppIdNotMatch if decrypted_appid != appid + end + + decrypted_message + end + + def encrypt_message(message, nonce, timestamp) + # 1. Encrypt and encode the xml message + encrypt = Base64.strict_encode64(encrypt(pack(message, appid), encoding_aes_key)) + + # 2. Create signature + sign = Signature.hexdigest(token, timestamp, nonce, encrypt) + + # 3. Construct xml + Message.to_xml(encrypt, sign, timestamp, nonce) + end + + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..b327b56 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,3 @@ +$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__))) + +require "we_whisper" diff --git a/spec/we_whisper/message_spec.rb b/spec/we_whisper/message_spec.rb new file mode 100644 index 0000000..23bcdbc --- /dev/null +++ b/spec/we_whisper/message_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe WeWhisper::Message do + + it "constructs XML message" do + expect(subject.to_xml("hello", "signature", "2016/10/10", "nonce")).to \ + eq """ + + +2016/10/10 + +""" + end + + describe "Message parsing" do + let(:hash_message) { { Encrypt: "hash_encrypted" } } + let(:xml_message) { """ +xml_encrypted_message +""" + } + + it "parses encrypted content from Hash message" do + expect(subject.get_encrypted_content_from_message(hash_message)).to \ + eq "hash_encrypted" + end + + it "parses encrypted content from XML message" do + expect(subject.get_encrypted_content_from_message(xml_message)).to \ + eq "xml_encrypted_message" + end + + it "raises invalid message class error from unknown message" do + expect{ subject.get_encrypted_content_from_message(nil) }.to \ + raise_error WeWhisper::InvalidMessageClassError + end + end + +end diff --git a/spec/we_whisper/whisper_spec.rb b/spec/we_whisper/whisper_spec.rb new file mode 100644 index 0000000..acf0e56 --- /dev/null +++ b/spec/we_whisper/whisper_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe WeWhisper::Whisper do + + let(:timestamp) { "1415979516" } + let(:nonce) { "1320562132" } + let(:signature) { "096d8cda45e4678ca23460f6b8cd281b3faf1fc3" } + let(:message) { "1407743423 " } + let(:whisper) { WeWhisper::Whisper.new "wx2c2769f8efd9abc2", "spamtest", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" } + let(:encrypted_message) { + """ + + +1415979516 + +""" + } + + it "decrypts message" do + decrypted_message = whisper.decrypt_message(encrypted_message, nonce, timestamp) + expect(decrypted_message).to eq message + end + + it "encryptes message" do + expect(SecureRandom).to receive(:hex).with(8).and_return("HLFOQjbkfgUh46s8") + encrypted_msg = whisper.encrypt_message(message, nonce, timestamp) + + expect(encrypted_msg).to eq encrypted_message + end + +end diff --git a/we_whisper.gemspec b/we_whisper.gemspec new file mode 100644 index 0000000..a4a3715 --- /dev/null +++ b/we_whisper.gemspec @@ -0,0 +1,21 @@ +require File.expand_path('../lib/we_whisper/version.rb', __FILE__) + +Gem::Specification.new do |s| + s.name = 'we_whisper' + s.version = WeWhisper::VERSION + s.date = '2016-03-30' + s.summary = "Wechat Message Encryption Wrapper." + s.description = <<-DESC + A Ruby Wrapper for Wechat Message Encryption. + DESC + s.authors = ["Qi He"] + s.email = 'qihe229@gmail.com' + s.homepage = 'http://github.com/he9qi/we_whisper' + s.license = 'MIT' + + s.files = Dir.glob('lib/**/*.rb') + s.require_paths = ['lib'] + s.test_files = Dir.glob('spec/**/*.rb') + + s.add_runtime_dependency "nokogiri", '~> 1.6.7.2' +end -- libgit2 0.21.0