Commit cd5842a671b0984a42e3ca5c264d862f2abdade8
0 parents
Exists in
master
first commit
Showing
16 changed files
with
415 additions
and
0 deletions
Show diff stats
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 | ... | ... |
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 | +``` | ... | ... |
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') | ... | ... |
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 | ... | ... |
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 | ... | ... |
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 | ... | ... |
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 | ... | ... |
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 | ... | ... |
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 | ... | ... |
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 | ... | ... |