Commit cd5842a671b0984a42e3ca5c264d862f2abdade8

Authored by Qi He
0 parents
Exists in master

first commit

.gitignore 0 → 100644
  1 +++ a/.gitignore
... ... @@ -0,0 +1 @@
  1 +tmp
... ...
Gemfile 0 → 100644
  1 +++ a/Gemfile
... ... @@ -0,0 +1,11 @@
  1 +ruby '2.0.0'
  2 +
  3 +source 'https://rubygems.org'
  4 +
  5 +gem 'nokogiri' # parse xml, TODO: use a lighter gem
  6 +
  7 +group :development, :test do
  8 + gem 'rspec'
  9 + gem 'rspec-core'
  10 + gem 'guard-rspec', require: false
  11 +end
... ...
Gemfile.lock 0 → 100644
  1 +++ a/Gemfile.lock
... ... @@ -0,0 +1,65 @@
  1 +GEM
  2 + remote: https://rubygems.org/
  3 + specs:
  4 + coderay (1.1.1)
  5 + diff-lcs (1.2.5)
  6 + ffi (1.9.10)
  7 + formatador (0.2.5)
  8 + guard (2.13.0)
  9 + formatador (>= 0.2.4)
  10 + listen (>= 2.7, <= 4.0)
  11 + lumberjack (~> 1.0)
  12 + nenv (~> 0.1)
  13 + notiffany (~> 0.0)
  14 + pry (>= 0.9.12)
  15 + shellany (~> 0.0)
  16 + thor (>= 0.18.1)
  17 + guard-compat (1.2.1)
  18 + guard-rspec (4.6.4)
  19 + guard (~> 2.1)
  20 + guard-compat (~> 1.1)
  21 + rspec (>= 2.99.0, < 4.0)
  22 + listen (3.0.6)
  23 + rb-fsevent (>= 0.9.3)
  24 + rb-inotify (>= 0.9.7)
  25 + lumberjack (1.0.10)
  26 + method_source (0.8.2)
  27 + mini_portile2 (2.0.0)
  28 + nenv (0.3.0)
  29 + nokogiri (1.6.7.2)
  30 + mini_portile2 (~> 2.0.0.rc2)
  31 + notiffany (0.0.8)
  32 + nenv (~> 0.1)
  33 + shellany (~> 0.0)
  34 + pry (0.10.3)
  35 + coderay (~> 1.1.0)
  36 + method_source (~> 0.8.1)
  37 + slop (~> 3.4)
  38 + rb-fsevent (0.9.7)
  39 + rb-inotify (0.9.7)
  40 + ffi (>= 0.5.0)
  41 + rspec (3.4.0)
  42 + rspec-core (~> 3.4.0)
  43 + rspec-expectations (~> 3.4.0)
  44 + rspec-mocks (~> 3.4.0)
  45 + rspec-core (3.4.4)
  46 + rspec-support (~> 3.4.0)
  47 + rspec-expectations (3.4.0)
  48 + diff-lcs (>= 1.2.0, < 2.0)
  49 + rspec-support (~> 3.4.0)
  50 + rspec-mocks (3.4.1)
  51 + diff-lcs (>= 1.2.0, < 2.0)
  52 + rspec-support (~> 3.4.0)
  53 + rspec-support (3.4.1)
  54 + shellany (0.0.1)
  55 + slop (3.6.0)
  56 + thor (0.19.1)
  57 +
  58 +PLATFORMS
  59 + ruby
  60 +
  61 +DEPENDENCIES
  62 + guard-rspec
  63 + nokogiri
  64 + rspec
  65 + rspec-core
... ...
Guardfile 0 → 100644
  1 +++ a/Guardfile
... ... @@ -0,0 +1,5 @@
  1 +guard :rspec, cmd: "bin/rspec --color" do
  2 + watch(%r{^spec/.+_spec\.rb$})
  3 + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
  4 + watch('spec/spec_helper.rb') { "spec" }
  5 +end
... ...
README.md 0 → 100644
  1 +++ a/README.md
... ... @@ -0,0 +1,26 @@
  1 +Wechat Message Encryption Wrapper
  2 +========
  3 +
  4 +[微信加密解密技术方案](https://open.weixin.qq.com/cgi-bin/showdocument?action=dir_list&t=resource/res_list&verify=1&id=open1419318482&token=6e18ec982b3bc11a95683a6b6045cd3cf373f09d&lang=zh_CN)
  5 +
  6 +### install gem
  7 +```
  8 +gem install 'we_whisper'
  9 +
  10 +or using bundler:
  11 +gem 'we_whisper'
  12 +```
  13 +
  14 +
  15 +#### Decrypt message
  16 +
  17 +```Ruby
  18 +whisper.decrypt_message(encrypted_message, nonce, timestamp)
  19 +```
  20 +
  21 +
  22 +#### Encrypt message
  23 +
  24 +```Ruby
  25 +whisper.encrypt_message(message, nonce, timestamp)
  26 +```
... ...
bin/rspec 0 → 100755
  1 +++ a/bin/rspec
... ... @@ -0,0 +1,16 @@
  1 +#!/usr/bin/env ruby
  2 +#
  3 +# This file was generated by Bundler.
  4 +#
  5 +# The application 'rspec' is installed as part of a gem, and
  6 +# this file is here to facilitate running it.
  7 +#
  8 +
  9 +require 'pathname'
  10 +ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile",
  11 + Pathname.new(__FILE__).realpath)
  12 +
  13 +require 'rubygems'
  14 +require 'bundler/setup'
  15 +
  16 +load Gem.bin_path('rspec-core', 'rspec')
... ...
lib/we_whisper.rb 0 → 100644
  1 +++ a/lib/we_whisper.rb
... ... @@ -0,0 +1,6 @@
  1 +module WeWhisper
  2 + require_relative 'we_whisper/cipher'
  3 + require_relative 'we_whisper/signature'
  4 + require_relative 'we_whisper/message'
  5 + require_relative 'we_whisper/whisper'
  6 +end
... ...
lib/we_whisper/cipher.rb 0 → 100644
  1 +++ a/lib/we_whisper/cipher.rb
... ... @@ -0,0 +1,73 @@
  1 +# Credit: https://github.com/Eric-Guo/wechat/blob/master/lib/wechat/cipher.rb
  2 +
  3 +require 'openssl/cipher'
  4 +require 'securerandom'
  5 +require 'base64'
  6 +
  7 +module WeWhisper
  8 + module Cipher
  9 +
  10 + BLOCK_SIZE = 32
  11 + CIPHER = 'AES-256-CBC'.freeze
  12 +
  13 + def encrypt(plain, encoding_aes_key)
  14 + cipher = OpenSSL::Cipher.new(CIPHER)
  15 + cipher.encrypt
  16 +
  17 + cipher.padding = 0
  18 + key_data = Base64.decode64(encoding_aes_key + '=')
  19 + cipher.key = key_data
  20 + cipher.iv = key_data[0..16]
  21 +
  22 + cipher.update(plain) + cipher.final
  23 + end
  24 +
  25 + def decrypt(msg, encoding_aes_key)
  26 + cipher = OpenSSL::Cipher.new(CIPHER)
  27 + cipher.decrypt
  28 +
  29 + cipher.padding = 0
  30 + key_data = Base64.decode64(encoding_aes_key + '=')
  31 + cipher.key = key_data
  32 + cipher.iv = key_data[0..16]
  33 +
  34 + plain = cipher.update(msg) + cipher.final
  35 + decode_padding(plain)
  36 + end
  37 +
  38 + # app_id or corp_id
  39 + def pack(content, app_id)
  40 + random = SecureRandom.hex(8)
  41 + text = content.force_encoding('ASCII-8BIT')
  42 + msg_len = [text.length].pack('N')
  43 +
  44 + encode_padding("#{random}#{msg_len}#{text}#{app_id}")
  45 + end
  46 +
  47 + def unpack(msg)
  48 + msg = decode_padding(msg)
  49 + msg_len = msg[16, 4].reverse.unpack('V')[0]
  50 + content = msg[20, msg_len]
  51 + app_id = msg[(20 + msg_len)..-1]
  52 +
  53 + [content, app_id]
  54 + end
  55 +
  56 + private
  57 +
  58 + def encode_padding(data)
  59 + length = data.bytes.length
  60 + amount_to_pad = BLOCK_SIZE - (length % BLOCK_SIZE)
  61 + amount_to_pad = BLOCK_SIZE if amount_to_pad == 0
  62 + padding = ([amount_to_pad].pack('c') * amount_to_pad)
  63 + data + padding
  64 + end
  65 +
  66 + def decode_padding(plain)
  67 + pad = plain.bytes[-1]
  68 + # no padding
  69 + pad = 0 if pad < 1 || pad > BLOCK_SIZE
  70 + plain[0...(plain.length - pad)]
  71 + end
  72 + end
  73 +end
... ...
lib/we_whisper/message.rb 0 → 100644
  1 +++ a/lib/we_whisper/message.rb
... ... @@ -0,0 +1,40 @@
  1 +require 'nokogiri'
  2 +
  3 +module WeWhisper
  4 +
  5 + InvalidMessageClassError = Class.new StandardError
  6 +
  7 + module Message
  8 + extend self
  9 +
  10 + def to_xml(content, signature, timestamp, nonce)
  11 +"""<xml>
  12 +<Encrypt><![CDATA[#{content}]]></Encrypt>
  13 +<MsgSignature><![CDATA[#{signature}]]></MsgSignature>
  14 +<TimeStamp>#{timestamp}</TimeStamp>
  15 +<Nonce><![CDATA[#{nonce}]]></Nonce>
  16 +</xml>"""
  17 + end
  18 +
  19 + def get_value_of_key_in_message(message, key)
  20 + case message.class.name
  21 + when "String"
  22 + doc = Nokogiri::XML(message)
  23 + doc.at_xpath("//#{key}").content
  24 + when "Hash"
  25 + message[key] || message[key.to_sym]
  26 + else
  27 + raise InvalidMessageClassError, "Message can only be a String or a Hash"
  28 + end
  29 + end
  30 +
  31 + def get_encrypted_content_from_message(message)
  32 + get_value_of_key_in_message(message, "Encrypt")
  33 + end
  34 +
  35 + def get_signature_from_messge(message)
  36 + get_value_of_key_in_message(message, "MsgSignature")
  37 + end
  38 +
  39 + end
  40 +end
... ...
lib/we_whisper/signature.rb 0 → 100644
  1 +++ a/lib/we_whisper/signature.rb
... ... @@ -0,0 +1,11 @@
  1 +require 'digest/sha2'
  2 +
  3 +module WeWhisper
  4 + module Signature
  5 + def self.hexdigest(token, timestamp, nonce, msg_encrypt)
  6 + array = [token, timestamp, nonce]
  7 + array << msg_encrypt unless msg_encrypt.nil?
  8 + Digest::SHA1.hexdigest array.compact.collect(&:to_s).sort.join
  9 + end
  10 + end
  11 +end
... ...
lib/we_whisper/version.rb 0 → 100644
  1 +++ a/lib/we_whisper/version.rb
... ... @@ -0,0 +1,3 @@
  1 +module WeWhisper
  2 + VERSION = '0.0.1'
  3 +end
... ...
lib/we_whisper/whisper.rb 0 → 100644
  1 +++ a/lib/we_whisper/whisper.rb
... ... @@ -0,0 +1,65 @@
  1 +require "openssl"
  2 +require 'digest/sha2'
  3 +require 'base64'
  4 +require 'securerandom'
  5 +require 'nokogiri'
  6 +
  7 +require_relative 'cipher'
  8 +require_relative 'signature'
  9 +require_relative 'message'
  10 +
  11 +module WeWhisper
  12 + InvalidSignature = Class.new StandardError
  13 + AppIdNotMatch = Class.new StandardError
  14 +
  15 + class Whisper
  16 + include Cipher
  17 +
  18 + attr_reader :appid, :encoding_aes_key, :token, :options
  19 +
  20 + def initialize(appid, token, encoding_aes_key, opts={})
  21 + @options = {
  22 + assert_signature: true,
  23 + assert_appid: true
  24 + }.merge(opts)
  25 +
  26 + @appid = appid
  27 + @token = token
  28 + @encoding_aes_key = encoding_aes_key
  29 + end
  30 +
  31 + def decrypt_message(message, nonce="", timestamp="")
  32 + # 1. Get the encrypted content from XML Message
  33 + encrypted_text = Message.get_encrypted_content_from_message(message)
  34 +
  35 + # 2. If we need to validate signature, generate one from the encrypted text
  36 + # and check with the Signature in message
  37 + if options[:assert_signature] && signature = Message.get_signature_from_messge(message)
  38 + sign = Signature.hexdigest(token, timestamp, nonce, encrypted_text)
  39 + raise InvalidSignature if sign != signature
  40 + end
  41 +
  42 + # 3. Decode and decrypt the encrypted text
  43 + decrypted_message, decrypted_appid = \
  44 + unpack(decrypt(Base64.decode64(encrypted_text), encoding_aes_key))
  45 +
  46 + if options[:assert_appid]
  47 + raise AppIdNotMatch if decrypted_appid != appid
  48 + end
  49 +
  50 + decrypted_message
  51 + end
  52 +
  53 + def encrypt_message(message, nonce, timestamp)
  54 + # 1. Encrypt and encode the xml message
  55 + encrypt = Base64.strict_encode64(encrypt(pack(message, appid), encoding_aes_key))
  56 +
  57 + # 2. Create signature
  58 + sign = Signature.hexdigest(token, timestamp, nonce, encrypt)
  59 +
  60 + # 3. Construct xml
  61 + Message.to_xml(encrypt, sign, timestamp, nonce)
  62 + end
  63 +
  64 + end
  65 +end
... ...
spec/spec_helper.rb 0 → 100644
  1 +++ a/spec/spec_helper.rb
... ... @@ -0,0 +1,3 @@
  1 +$LOAD_PATH.unshift(File.expand_path(File.dirname(__FILE__)))
  2 +
  3 +require "we_whisper"
... ...
spec/we_whisper/message_spec.rb 0 → 100644
  1 +++ a/spec/we_whisper/message_spec.rb
... ... @@ -0,0 +1,38 @@
  1 +require 'spec_helper'
  2 +
  3 +describe WeWhisper::Message do
  4 +
  5 + it "constructs XML message" do
  6 + expect(subject.to_xml("hello", "signature", "2016/10/10", "nonce")).to \
  7 + eq """<xml>
  8 +<Encrypt><![CDATA[hello]]></Encrypt>
  9 +<MsgSignature><![CDATA[signature]]></MsgSignature>
  10 +<TimeStamp>2016/10/10</TimeStamp>
  11 +<Nonce><![CDATA[nonce]]></Nonce>
  12 +</xml>"""
  13 + end
  14 +
  15 + describe "Message parsing" do
  16 + let(:hash_message) { { Encrypt: "hash_encrypted" } }
  17 + let(:xml_message) { """<xml>
  18 +<Encrypt>xml_encrypted_message</Encrypt>
  19 +</xml>"""
  20 + }
  21 +
  22 + it "parses encrypted content from Hash message" do
  23 + expect(subject.get_encrypted_content_from_message(hash_message)).to \
  24 + eq "hash_encrypted"
  25 + end
  26 +
  27 + it "parses encrypted content from XML message" do
  28 + expect(subject.get_encrypted_content_from_message(xml_message)).to \
  29 + eq "xml_encrypted_message"
  30 + end
  31 +
  32 + it "raises invalid message class error from unknown message" do
  33 + expect{ subject.get_encrypted_content_from_message(nil) }.to \
  34 + raise_error WeWhisper::InvalidMessageClassError
  35 + end
  36 + end
  37 +
  38 +end
... ...
spec/we_whisper/whisper_spec.rb 0 → 100644
  1 +++ a/spec/we_whisper/whisper_spec.rb
... ... @@ -0,0 +1,31 @@
  1 +require 'spec_helper'
  2 +
  3 +describe WeWhisper::Whisper do
  4 +
  5 + let(:timestamp) { "1415979516" }
  6 + let(:nonce) { "1320562132" }
  7 + let(:signature) { "096d8cda45e4678ca23460f6b8cd281b3faf1fc3" }
  8 + let(:message) { "<xml><ToUserName><![CDATA[oia2TjjewbmiOUlr6X-1crbLOvLw]]></ToUserName><FromUserName><![CDATA[gh_7f083739789a]]></FromUserName><CreateTime>1407743423</CreateTime><MsgType> <![CDATA[video]]></MsgType><Video><MediaId><![CDATA[eYJ1MbwPRJtOvIEabaxHs7TX2D-HV71s79GUxqdUkjm6Gs2Ed1KF3ulAOA9H1xG0]]></MediaId><Title><![CDATA[testCallBackReplyVideo]]></Title><Description><![CDATA[testCallBackReplyVideo]]></Description></Video></xml>" }
  9 + let(:whisper) { WeWhisper::Whisper.new "wx2c2769f8efd9abc2", "spamtest", "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG" }
  10 + let(:encrypted_message) {
  11 + """<xml>
  12 +<Encrypt><![CDATA[3kKZ++U5ocvIF8dAHPct7xvUqEv6vplhuzA8Vwj7OnVcBu9fdmbbI41zclSfKqP6/bdYAxuE3x8jse43ImHaV07siJF473TsXhl8Yt8task0n9KC7BDA73mFTwlhYvuCIFnU6wFlzOkHyM5Bh2qpOHYk5nSMRyUG4BwmXpxq8TvLgJV1jj2DXdGW4qdknGLfJgDH5sCPJeBzNC8j8KtrJFxmG7qIwKHn3H5sqBf6UqhXFdbLuTWL3jwE7yMLhzOmiHi/MX/ZsVQ7sMuBiV6bW0wkgielESC3yNUPo4q/RMAFEH0fRLr76BR5Ct0nUbf9PdClc0RdlYcztyOs54X/KLbYRNCQ2kXxmJYL6ekdNe70PCAReIEfXEp+pGpry4ss8bD6LKAtNvBJUwHshZe6sbf+fOiDiuKEqp1wdQLmgN+8nX62LklySWr8QrNCpsmKClxco0kbVYNX/QVh5yd0UA1sAqIn6baZ9G+Z/OXG+Q4n9lUuzLprLhDBPaCvXm4N14oqXNcw7tqU2xfhYNIDaD72djyIc/4eyAi2ZsJ+3hb+jgiISR5WVveRWYYqGZGTW3u+27JiXEo0fs3DQDbGVIcYxaMgU/RRIDdXzZSFcf6Z1azjzCDyV9FFEsicghHn]]></Encrypt>
  13 +<MsgSignature><![CDATA[096d8cda45e4678ca23460f6b8cd281b3faf1fc3]]></MsgSignature>
  14 +<TimeStamp>1415979516</TimeStamp>
  15 +<Nonce><![CDATA[1320562132]]></Nonce>
  16 +</xml>"""
  17 + }
  18 +
  19 + it "decrypts message" do
  20 + decrypted_message = whisper.decrypt_message(encrypted_message, nonce, timestamp)
  21 + expect(decrypted_message).to eq message
  22 + end
  23 +
  24 + it "encryptes message" do
  25 + expect(SecureRandom).to receive(:hex).with(8).and_return("HLFOQjbkfgUh46s8")
  26 + encrypted_msg = whisper.encrypt_message(message, nonce, timestamp)
  27 +
  28 + expect(encrypted_msg).to eq encrypted_message
  29 + end
  30 +
  31 +end
... ...
we_whisper.gemspec 0 → 100644
  1 +++ a/we_whisper.gemspec
... ... @@ -0,0 +1,21 @@
  1 +require File.expand_path('../lib/we_whisper/version.rb', __FILE__)
  2 +
  3 +Gem::Specification.new do |s|
  4 + s.name = 'we_whisper'
  5 + s.version = WeWhisper::VERSION
  6 + s.date = '2016-03-30'
  7 + s.summary = "Wechat Message Encryption Wrapper."
  8 + s.description = <<-DESC
  9 + A Ruby Wrapper for Wechat Message Encryption.
  10 + DESC
  11 + s.authors = ["Qi He"]
  12 + s.email = 'qihe229@gmail.com'
  13 + s.homepage = 'http://github.com/he9qi/we_whisper'
  14 + s.license = 'MIT'
  15 +
  16 + s.files = Dir.glob('lib/**/*.rb')
  17 + s.require_paths = ['lib']
  18 + s.test_files = Dir.glob('spec/**/*.rb')
  19 +
  20 + s.add_runtime_dependency "nokogiri", '~> 1.6.7.2'
  21 +end
... ...