r2d2 0.1.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 026e3de4d7dca643f8f57f519a93014eaa3587ea
4
- data.tar.gz: 1a58252e22b3595cf709d132b10308012baae046
2
+ SHA256:
3
+ metadata.gz: 8fd5e6027086b7e2417c7839833ac9e5a4c895b42b8b798f59067dfc3974c82d
4
+ data.tar.gz: 40bcd0191eb22ea4f07b950fcc0628f287250dfb5476f128f0130463ecd91d34
5
5
  SHA512:
6
- metadata.gz: abf1504ec9d6bbcfc77f774052e4c66d073033d2eb562edfe397711e03252e41e51ddf764be8c6abc14be455a4641b43cb1527b703ec1fb49def7a0a2f708b4e
7
- data.tar.gz: 72bc939de0608e03740b44cfff6ba6941933ead007ba2c044b38f3c2e6964da749e8bdb355fad0b63658efbe4b4751f847fad37426363294c9de857d7fb9eb70
6
+ metadata.gz: 96107bd836d140f4a5bcde53490faf3307ba7771fd50008e2f77d9ab61091163cad14872ff337579d48a8c41ee6d8a01b4f6fc2461fac725729db9f17365a9f0
7
+ data.tar.gz: f1762817389f164a1b5901497c46def4385646c66db5f945793bded47e459a99d60daf217bf9ee6c60a10d61433e35d1e2a8612c609325cffe2f5924dee1a898
@@ -1,12 +1,12 @@
1
1
  version: 2
2
2
  jobs:
3
- ruby-2.1:
4
- docker:
5
- - image: circleci/ruby:2.1.10
6
- steps:
7
- - checkout
8
- - run: bundle
9
- - run: rake test
3
+ # ruby-2.1:
4
+ # docker:
5
+ # - image: circleci/ruby:2.1.10
6
+ # steps:
7
+ # - checkout
8
+ # - run: bundle
9
+ # - run: rake test
10
10
  ruby-2.2:
11
11
  docker:
12
12
  - image: circleci/ruby:2.2.10
@@ -39,7 +39,7 @@ workflows:
39
39
  version: 2
40
40
  rubies:
41
41
  jobs:
42
- - ruby-2.1
42
+ # - ruby-2.1
43
43
  - ruby-2.2
44
44
  # - ruby-2.3
45
45
  # - ruby-2.4
data/Gemfile CHANGED
@@ -1,5 +1,3 @@
1
1
  source 'https://rubygems.org'
2
2
 
3
3
  gemspec
4
-
5
- gem 'hkdf', '~> 0.2.0'
data/README.md CHANGED
@@ -2,21 +2,76 @@
2
2
 
3
3
  [![CircleCI](https://circleci.com/gh/spreedly/r2d2.svg?style=svg)](https://circleci.com/gh/spreedly/r2d2)
4
4
 
5
- R2D2 is a Ruby library for decrypting Android Pay payment tokens.
5
+ R2D2 is a Ruby library for decrypting Google Pay and Android Pay payment tokens.
6
6
 
7
7
  ## Ruby support
8
8
 
9
- Currently, only Ruby v2.2 and below are supported. For Ruby >= 2.3, work will need to be done (similar to [what was done in Gala](https://github.com/spreedly/gala/commit/0a4359ccdd5654b78747f9141645ca510ee255c2)) to use a compatible aead decryption algorithm.
9
+ Currently, Ruby v2.2 is supported. For Ruby >= 2.3, work will need to be done (similar to [what was done in Gala](https://github.com/spreedly/gala/commit/0a4359ccdd5654b78747f9141645ca510ee255c2)) to use a compatible aead decryption algorithm.
10
10
 
11
11
  ## Install
12
12
 
13
13
  Add to your `Gemfile`:
14
14
 
15
15
  ```ruby
16
- gem "android_pay", git: "https://github.com/spreedly/android_pay.git"
16
+ gem 'r2d2', git: 'https://github.com/spreedly/r2d2.git'
17
17
  ```
18
18
 
19
- ## Usage
19
+ ## Google Pay Usage
20
+
21
+ For Google Pay, R2D2 requires the token values in the form of a JSON hash, your `recipient_id`, Google's `verification_keys`,
22
+ and your private key.
23
+
24
+ Example Google Pay token values:
25
+
26
+ ```json
27
+ {
28
+ "signature": "MEYCIQD5mAtwoptfXuDnEVvtSbPmRnkw94GXEHjog24SfIe4rAIhAKLeSY4xcHLK1liBoZFaeZG+FrqawI7Id2mJXwddP3KH",
29
+ "protocolVersion": "ECv1",
30
+ "signedMessage": "{\"encryptedMessage\":\"jzo38/Ufbt9qh/scrTJmG9v8Cgb7Y5S+zCTTbSou/NoLoE/XF9ixyIGNIspKkH4ulwwVX0/EoqKDKk86XDLw8qBjx1tfHefbLuhZbqkfu/8bs5D6QMz8LjcJU+EeXYcdZ+KeQ3jzrgS6B9CqEJJIF+PeySMJtTwF9Fh+X2sW4Yg0C34mHz0MHpVUpmzJZblTwzMkCVOdq7eMF9Ywb8kDnRFasMYALbRaEOMg2o9gXSfGEVPhS8ors4SRFcnLoVPfktHRJtY/UZEREJvGFY/s/wpmU9sRADYTMKQ/ChTMumT+1NG0r4XibDcaZjW/Wlz1Dwog+dNMYUblPjY613sBLtjoBbRDYYVuDn/TUYXOJwAgXoHFfMmvWm0ne0n9eXggxoaMFFgF5zXk9ZLl3FyH/hi3WWtsFt5sqQWgFdjsqTriL6i46m46hMaZ9gKZ8JQE912IG5kZts5L8XSMiG94Z3UiTA\\u003d\\u003d\",\"ephemeralPublicKey\":\"BIeq42AvLcEhz0oLmYdj++oBTS5PD131FAEgx4y91cwqbkZMUKADkzj2bD4MxneqgqFYirO29+y/G6YH9zmfjlk\\u003d\",\"tag\":\"sRILsawzbm53+9tVTh9ooBP5ivzxWki73UJbuOZ3IYY\\u003d\"}"
31
+ }
32
+ ```
33
+
34
+ The `recipient_id` will be in the form `gateway:processorname`.
35
+
36
+ The `verificiation_keys` are available in Google's developer docs. Example:
37
+
38
+ ```json
39
+ { "keys":
40
+ [
41
+ { "keyValue":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIsFro6K+IUxRr4yFTOTO+kFCCEvHo7B9IOMLxah6c977oFzX\/beObH4a9OfosMHmft3JJZ6B3xpjIb8kduK4\/A==",
42
+ "protocolVersion":"ECv1"
43
+ }
44
+ ]
45
+ }
46
+ ```
47
+
48
+ ```ruby
49
+ require 'r2d2'
50
+
51
+ # token_attrs = Google Pay token values { "signature": "...", "protocolVersion": "...", ...}
52
+ token = R2D2.build_token(token_attrs, recipient_id: recipient_id, verification_keys: verification_keys)
53
+
54
+ private_key_pem = File.read('private_key.pem')
55
+ decrypted_json = token.decrypt(private_key_pem)
56
+
57
+ JSON.parse(decrypted_json)
58
+ # =>
59
+ {
60
+ "gatewayMerchantId" => "exampleGatewayMerchantId",
61
+ "messageExpiration" => "1528716120231",
62
+ "messageId" => "AH2EjtcpVGS3JvxlTP5kUbx3h0Laa30uVKjB9CqmnYiw8gZ-tpsxIoOdTbAU_DtCbkLVUPzkFeeqSbU1vTbAIAE4LlPHJqBiMMF4hZ5KRafml3764_6lK7aH7cQkIma40CI-rtCWTLCk",
63
+ "paymentMethod" => "CARD",
64
+ "paymentMethodDetails" =>
65
+ {
66
+ "expirationYear" => 2023,
67
+ "expirationMonth" => 12,
68
+ "pan" => "4111111111111111"
69
+ }
70
+ }
71
+ ```
72
+
73
+
74
+ ## Android Pay Usage
20
75
 
21
76
  R2D2 takes input in the form of the hash of Android Pay token values:
22
77
 
@@ -28,16 +83,15 @@ R2D2 takes input in the form of the hash of Android Pay token values:
28
83
  }
29
84
  ```
30
85
 
31
- and the merchant's private key private key (which is managed by a third-party such as a gateway or independent processor like [Spreedly](https://spreedly.com)).
86
+ and the merchant's private key (which is managed by a third-party such as a gateway or independent processor like [Spreedly](https://spreedly.com)).
32
87
 
33
88
  ```ruby
34
- require "android_pay"
89
+ require 'r2d2'
35
90
 
36
91
  # token_json = raw token string you get from Android Pay { "encryptedMessage": "...", "tag": "...", ...}
37
- token_attrs = JSON.parse(token_json)
38
- token = R2D2::PaymentToken.new(token_attrs)
92
+ token = R2D2.build_token(token_attrs)
39
93
 
40
- private_key_pem = File.read("private_key.pem")
94
+ private_key_pem = File.read('private_key.pem')
41
95
  decrypted_json = token.decrypt(private_key_pem)
42
96
 
43
97
  JSON.parse(decrypted_json)
@@ -52,21 +106,21 @@ JSON.parse(decrypted_json)
52
106
  }
53
107
  ```
54
108
 
55
- ### Performance
109
+ ## Performance
56
110
 
57
111
  The library implements a constant time comparison algorithm for preventing timing attacks. The default pure ruby implementation is quite inefficient, but portable. If performance is a priority for you, you can use a faster comparison algorithm provided by the [fast_secure_compare](https://github.com/daxtens/fast_secure_compare).
58
112
 
59
113
  To enable `FastSecureCompare` in your environment, add the following to your Gemfile:
60
114
 
61
115
  ```ruby
62
- gem 'fast_secure_compare`
116
+ gem 'fast_secure_compare'
63
117
  ```
64
118
 
65
119
  and require the extension in your application prior to loading r2d2:
66
120
 
67
121
  ```ruby
68
122
  require 'fast_secure_compare/fast_secure_compare'
69
- require 'r2d2/payment_token'
123
+ require 'r2d2'
70
124
  ```
71
125
 
72
126
  Benchmarks illustrating the overhead of the pure Ruby version:
@@ -98,7 +152,7 @@ $ curl -u rwdaigle https://rubygems.org/api/v1/api_key.yaml > ~/.gem/credentials
98
152
  <enter rubygems account password>
99
153
  ```
100
154
 
101
- If you are not yet listed as a gem owner, you will need to [request access](http://guides.rubygems.org/command-reference/#gem-owner) from @rwdaigle.
155
+ If you are not yet listed as a gem owner, you will need to [request access](https://github.com/rwdaigle) from @rwdaigle.
102
156
 
103
157
  ### Release
104
158
 
@@ -110,6 +164,13 @@ $ rake release
110
164
 
111
165
  ## Changelog
112
166
 
167
+ ### v1.0.0
168
+
169
+ * Breaking Changes: API now decrypts both Google Pay and Android Pay tokens
170
+ * New method call to decrypt Android Pay tokens
171
+ * Additional arguments included for Google Pay tokens
172
+ * Update README.md
173
+
113
174
  ### v0.1.2
114
175
 
115
176
  * Setup CircleCI for more exhaustive Ruby version compatibility tests
@@ -117,4 +178,8 @@ $ rake release
117
178
 
118
179
  ## Contributors
119
180
 
181
+ * [mrezentes](https://github.com/mrezentes)
182
+ * [rwdaigle](https://github.com/rwdaigle)
120
183
  * [methodmissing](https://github.com/methodmissing)
184
+ * [bdewater](https://github.com/bdewater)
185
+ * [deedeelavinder](https://github.com/deedeelavinder)
@@ -1,3 +1,7 @@
1
1
  require "json"
2
+ require 'openssl'
3
+ require 'base64'
2
4
 
3
- require_relative "r2d2/payment_token"
5
+ require_relative "r2d2/util"
6
+ require_relative "r2d2/android_pay_token"
7
+ require_relative "r2d2/google_pay_token"
@@ -0,0 +1,27 @@
1
+ module R2D2
2
+ class AndroidPayToken
3
+ include Util
4
+
5
+ attr_accessor :encrypted_message, :ephemeral_public_key, :tag
6
+
7
+ def initialize(token_attrs)
8
+ self.ephemeral_public_key = token_attrs["ephemeralPublicKey"]
9
+ self.tag = token_attrs["tag"]
10
+ self.encrypted_message = token_attrs["encryptedMessage"]
11
+ end
12
+
13
+ def decrypt(private_key_pem)
14
+ private_key = OpenSSL::PKey::EC.new(private_key_pem)
15
+
16
+ shared_secret = generate_shared_secret(private_key, ephemeral_public_key)
17
+
18
+ # derive the symmetric_encryption_key and mac_key
19
+ hkdf_keys = derive_hkdf_keys(ephemeral_public_key, shared_secret, 'Android')
20
+
21
+ # verify the tag is a valid value
22
+ verify_mac(hkdf_keys[:mac_key], encrypted_message, tag)
23
+
24
+ JSON.parse(decrypt_message(encrypted_message, hkdf_keys[:symmetric_encryption_key]))
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,54 @@
1
+ module R2D2
2
+ class GooglePayToken
3
+ include Util
4
+
5
+ attr_reader :protocol_version, :recipient_id, :verification_keys, :signature, :signed_message
6
+
7
+ def initialize(token_attrs, recipient_id:, verification_keys:)
8
+ @protocol_version = token_attrs['protocolVersion']
9
+ @recipient_id = recipient_id
10
+ @verification_keys = verification_keys
11
+ @signature = token_attrs['signature']
12
+ @signed_message = token_attrs['signedMessage']
13
+ end
14
+
15
+ def decrypt(private_key_pem)
16
+ verified = verify_and_parse_message
17
+
18
+ private_key = OpenSSL::PKey::EC.new(private_key_pem)
19
+ shared_secret = generate_shared_secret(private_key, verified['ephemeralPublicKey'])
20
+ hkdf_keys = derive_hkdf_keys(verified['ephemeralPublicKey'], shared_secret, 'Google')
21
+
22
+ verify_mac(hkdf_keys[:mac_key], verified['encryptedMessage'], verified['tag'])
23
+ decrypted = JSON.parse(
24
+ decrypt_message(verified['encryptedMessage'], hkdf_keys[:symmetric_encryption_key])
25
+ )
26
+
27
+ expired = decrypted['messageExpiration'].to_f / 1000.0 <= Time.now.to_f
28
+ raise MessageExpiredError if expired
29
+
30
+ decrypted
31
+ end
32
+
33
+ private
34
+
35
+ def verify_and_parse_message
36
+ digest = OpenSSL::Digest::SHA256.new
37
+ signed_bytes = to_length_value(
38
+ 'Google',
39
+ recipient_id,
40
+ protocol_version,
41
+ signed_message
42
+ )
43
+ verified = verification_keys['keys'].any? do |key|
44
+ next if key['protocolVersion'] != protocol_version
45
+
46
+ ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
47
+ ec.verify(digest, Base64.strict_decode64(signature), signed_bytes)
48
+ end
49
+
50
+ raise SignatureInvalidError unless verified
51
+ JSON.parse(signed_message)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,104 @@
1
+ module R2D2
2
+ Error = Class.new(StandardError)
3
+ TagVerificationError = Class.new(R2D2::Error)
4
+ SignatureInvalidError = Class.new(R2D2::Error)
5
+ MessageExpiredError = Class.new(R2D2::Error)
6
+
7
+ def build_token(token_attrs, recipient_id: nil, verification_keys: nil)
8
+ protocol_version = token_attrs.fetch('protocolVersion', 'ECv0')
9
+
10
+ case protocol_version
11
+ when 'ECv0'
12
+ AndroidPayToken.new(token_attrs)
13
+ when 'ECv1'
14
+ raise ArgumentError, "missing keyword: recipient_id" if recipient_id.nil?
15
+ raise ArgumentError, "missing keyword: verification_keys" if verification_keys.nil?
16
+
17
+ GooglePayToken.new(token_attrs, recipient_id: recipient_id, verification_keys: verification_keys)
18
+ else
19
+ raise ArgumentError, "unknown protocolVersion #{protocol_version}"
20
+ end
21
+ end
22
+ module_function :build_token
23
+
24
+ module Util
25
+ def generate_shared_secret(private_key, ephemeral_public_key)
26
+ ec = OpenSSL::PKey::EC.new('prime256v1')
27
+ bn = OpenSSL::BN.new(Base64.decode64(ephemeral_public_key), 2)
28
+ point = OpenSSL::PKey::EC::Point.new(ec.group, bn)
29
+ private_key.dh_compute_key(point)
30
+ end
31
+
32
+ def derive_hkdf_keys(ephemeral_public_key, shared_secret, info)
33
+ key_material = Base64.decode64(ephemeral_public_key) + shared_secret
34
+ hkdf_bytes = hkdf(key_material, info)
35
+ {
36
+ symmetric_encryption_key: hkdf_bytes[0..15],
37
+ mac_key: hkdf_bytes[16..32]
38
+ }
39
+ end
40
+
41
+ def verify_mac(mac_key, encrypted_message, tag)
42
+ digest = OpenSSL::Digest.new('sha256')
43
+ mac = OpenSSL::HMAC.digest(digest, mac_key, Base64.decode64(encrypted_message))
44
+ raise TagVerificationError unless secure_compare(mac, Base64.decode64(tag))
45
+ end
46
+
47
+ def decrypt_message(encrypted_data, symmetric_key)
48
+ decipher = OpenSSL::Cipher::AES128.new(:CTR)
49
+ decipher.decrypt
50
+ decipher.key = symmetric_key
51
+ decipher.update(Base64.decode64(encrypted_data)) + decipher.final
52
+ end
53
+
54
+ def to_length_value(*chunks)
55
+ value = ''
56
+ chunks.each do |chunk|
57
+ chunk_size = 4.times.map do |index|
58
+ (chunk.bytesize >> (8 * index)) & 0xFF
59
+ end
60
+ value << chunk_size.pack('C*')
61
+ value << chunk
62
+ end
63
+ value
64
+ end
65
+
66
+ private
67
+
68
+ if defined?(FastSecureCompare)
69
+ def secure_compare(a, b)
70
+ FastSecureCompare.compare(a, b)
71
+ end
72
+ else
73
+ # constant-time comparison algorithm to prevent timing attacks; borrowed from ActiveSupport::MessageVerifier
74
+ def secure_compare(a, b)
75
+ return false unless a.bytesize == b.bytesize
76
+
77
+ l = a.unpack("C#{a.bytesize}")
78
+
79
+ res = 0
80
+ b.each_byte { |byte| res |= byte ^ l.shift }
81
+ res == 0
82
+ end
83
+ end
84
+
85
+ if defined?(OpenSSL::KDF) && OpenSSL::KDF.respond_to?(:hkdf)
86
+ def hkdf(key_material, info)
87
+ OpenSSL::KDF.hkdf(key_material, salt: 0.chr * 32, info: info, length: 32, hash: 'sha256')
88
+ end
89
+ else
90
+ begin
91
+ require 'hkdf'
92
+ rescue LoadError
93
+ STDERR.puts "You need at least Ruby OpenSSL gem 2.1 (installed: #{OpenSSL::VERSION}) " \
94
+ "and system OpenSSL 1.1.0 (installed: #{OpenSSL::OPENSSL_LIBRARY_VERSION}) for HKDF support." \
95
+ "You can add \"gem 'hkdf'\" to your Gemfile for a Ruby-based fallback."
96
+ raise
97
+ end
98
+
99
+ def hkdf(key_material, info)
100
+ HKDF.new(key_material, algorithm: 'SHA256', info: info).next_bytes(32)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -1,3 +1,3 @@
1
1
  module R2D2
2
- VERSION = "0.1.2" unless defined? R2D2::VERSION
2
+ VERSION = "1.0.0" unless defined? R2D2::VERSION
3
3
  end
@@ -16,12 +16,13 @@ Gem::Specification.new do |s|
16
16
  s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
17
17
  s.require_paths = ["lib"]
18
18
 
19
- s.required_ruby_version = ">= 1.8.7"
19
+ s.required_ruby_version = ">= 2.2"
20
20
 
21
21
  s.add_runtime_dependency 'hkdf'
22
22
 
23
23
  s.add_development_dependency "bundler", "~> 1.15"
24
24
  s.add_development_dependency "rake", "~> 12.0"
25
25
  s.add_development_dependency "minitest", "~> 5.0"
26
+ s.add_development_dependency "timecop"
26
27
  s.add_development_dependency "pry-byebug"
27
28
  end
@@ -0,0 +1,33 @@
1
+ require "test_helper"
2
+
3
+ class R2D2::AndroidPayTokenTest < Minitest::Test
4
+ def setup
5
+ fixtures = File.dirname(__FILE__) + "/fixtures/"
6
+ @token_attrs = JSON.parse(File.read(fixtures + "token.json"))
7
+ @private_key = File.read(fixtures + "private_key.pem")
8
+ @payment_token = R2D2::AndroidPayToken.new(@token_attrs)
9
+ end
10
+
11
+ def test_initialize
12
+ assert_equal @token_attrs["ephemeralPublicKey"], @payment_token.ephemeral_public_key
13
+ assert_equal @token_attrs["tag"], @payment_token.tag
14
+ assert_equal @token_attrs["encryptedMessage"], @payment_token.encrypted_message
15
+ end
16
+
17
+ def test_successful_decrypt
18
+ payment_data = @payment_token.decrypt(@private_key)
19
+ assert_equal "4895370012003478", payment_data["dpan"]
20
+ assert_equal 12, payment_data["expirationMonth"]
21
+ assert_equal 2020, payment_data["expirationYear"]
22
+ assert_equal "3DS", payment_data["authMethod"]
23
+ assert_equal "AgAAAAAABk4DWZ4C28yUQAAAAAA=", payment_data["3dsCryptogram"]
24
+ assert_equal "07", payment_data["3dsEciIndicator"]
25
+ end
26
+
27
+ def test_invalid_tag
28
+ @payment_token.tag = "SomethingBogus"
29
+ assert_raises R2D2::TagVerificationError do
30
+ @payment_token.decrypt(@private_key)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,5 @@
1
+ {
2
+ "signature": "MEUCIQDeIvCbFje6fFQe0lFUD8oV6T3GY81dU9UJ6KeXlwKSJgIgamgC56BdcNEJgffQDW3bTFWmEQVoC6zUZ+J7xJp2WAg\u003d",
3
+ "protocolVersion": "ECv1",
4
+ "signedMessage": "{\"encryptedMessage\":\"ilnxpVEEaaZ2GFSc7cQDNxSMkjF5nWFcT6w3HSdpX+5mXn/uv6naeTjSoGfy1gi6JdpToKuc0hhkqpetNVDj6dPH8u01K685xEL0AVjuSR84WnRfxnWjDNlYRuGS0N5iPY6G/981ucbgwBNcAjqemTdc6xv6/lQwZq36V7FWt4IAchg55JJt2mnFMSDkkMTz5bWfJtgmwg55b0KXfuTA3ADLxeFlOLuT3DICFzo45jyCK1RSltiCHhD+Lm65wESCHCgS9W50yh2a/J1lTCUP7Xy/aaurVtNUrB+H/SlY16Szk455h27d2Zi6JKzxnL/rQ+5ME1TWpp0n66RQ70y1roC5myNy6ILmEoyIdAU6FahuGX4MQFkmO4G4GOL5lMEj63hF1U2N/iFm03Urx2dSWkCsiVM\\u003d\",\"ephemeralPublicKey\":\"BEENTno093zpl+QST4mXKVqrOVHsqweffqILO+HAob2JyF8YNtjBWXoKunSF0rkGY3spzp/BsNHKJ3Req+DfOwM\\u003d\",\"tag\":\"eZJ0p0DUu9TGM3bOx8VBCcgt9sJ9tgLRnLjgPyd4aKs\\u003d\"}"
5
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "keys": [
3
+ {
4
+ "keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAENnoaYTAh15xpR65XRw7jHYj7vNUIGu5I4OmLCrORWwdjrcrED+bJo+nF2HyA5hnH12Dqt1bR8mqKBXynG3HBNw==",
5
+ "protocolVersion": "ECv1"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "keys": [
3
+ {
4
+ "keyValue": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIsFro6K+IUxRr4yFTOTO+kFCCEvHo7B9IOMLxah6c977oFzX\/beObH4a9OfosMHmft3JJZ6B3xpjIb8kduK4\/A==",
5
+ "protocolVersion": "ECv1"
6
+ }
7
+ ]
8
+ }
@@ -0,0 +1,5 @@
1
+ -----BEGIN EC PRIVATE KEY-----
2
+ MHcCAQEEIIvzjN8pvJsygwyvbp1zKqH2SMUwMkM3HKNeMHx1sbC5oAoGCCqGSM49
3
+ AwEHoUQDQgAEkveyWrkD745R6yWrJxjjBX+XPkGkbZCs7pqXyu4GSfqyVR1yq/Pa
4
+ RtfNkXhuqHBSjgo2hV2DK1bXfsc0VKt3mg==
5
+ -----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
1
+ {
2
+ "signature": "MEUCIQDzgQkY/Lj42FoZC9wN44Uz0r6uDttc68k2c9oZCzNJPwIgSRukEx30uMjqzFswekoarAtO9p1/E221hNmVuHzDW+M\u003d",
3
+ "protocolVersion": "ECv1",
4
+ "signedMessage": "{\"encryptedMessage\":\"RrliMMApO0MghHfjfVbJDz3jmdwQYluu8yjLPqnsRUZ36s28ceb+KxEdFIaxC2Y/d6hUm7kUOpyLbCMlj0IZXUP7WmGxHW+oQr8BDiHmwlRzovO3WCyB91kci0dz17k5qKaT/6Pf3FMu+0IhiGd+QtD6d+ifv6bvy6iA0rwpGMHheC/gKqZF4xCYWJjw7Twa72xEbjp9qlO0msA8jysjuB+N2injsL43I/lB592I/V9VZJvI5S7TDZi8XQb1kNKNmhH1XDEseT3Mb180xtJxCxuVOpk9yH7NyRWha3HV6Tgr+GqSSk5jCzZyY/PxxwTjnRvRPmcrd7PTN+w1k4M4TdniyStmdk8yUdxgd+utNB3Xwh4J8caFtDVZdRYgoDx5S/3zJ6xQckqQzVD0fRuykVtMjY++EMbNfNdGKV0u8mAEBVXJaPVsxISCgBJTvpNzllj3DMvNEE/Zc5p+7izMh8mz++g5ab5mrw/Xg//dYm/ndKG6V9WXqosTg4IdhsWALgCLyYVZwxjsjF3kua0XZ2IyemsvGU0KtZL9Uy4\\u003d\",\"ephemeralPublicKey\":\"BCVPKilVgN/Mg7cg4nkLZnvMZ0EIONH+Cnlq6ykhWk/lq+fHHgH+lOoy0MXvI7ZfDL6AesSO8+mqLl4MwwHDd6o\\u003d\",\"tag\":\"P/m+wFoQ7yQHepwhGdGvp08TSPPuO1a5SyF9C5QKIe4\\u003d\"}"
5
+ }
@@ -0,0 +1,105 @@
1
+ require "test_helper"
2
+
3
+ module R2D2
4
+ class GooglePayTokenTest < Minitest::Test
5
+ def setup
6
+ @recipient_id = 'merchant:12345678901234567890'
7
+ @fixtures = __dir__ + "/fixtures/ec_v1/"
8
+ @token = JSON.parse(File.read(@fixtures + "tokenized_card.json"))
9
+ @private_key = File.read(@fixtures + "private_key.pem")
10
+ @verification_keys = JSON.parse(File.read(@fixtures + "google_verification_key_test.json"))
11
+ Timecop.freeze(Time.at(1509713963))
12
+ end
13
+
14
+ def teardown
15
+ Timecop.return
16
+ end
17
+
18
+ def test_decrypted_tokenized_card
19
+ expected = {
20
+ "messageExpiration" => "1510318759535",
21
+ "paymentMethod" => "TOKENIZED_CARD",
22
+ "messageId" => "AH2EjtfMnpeHvgqYbBDLAxPzyYlPOmOa792BqdsvTc2T7jsn23_us0dKU509I-AA9dVDLf9_v4c5ldxoge6Q3iYr9acGGSyD9ojbOTP1fjWzDteVE_yf1pGzGNQ2Q6jKG96KRpbIaziY",
23
+ "paymentMethodDetails" =>
24
+ {
25
+ "expirationYear" => 2022,
26
+ "dpan" => "4895370012003478",
27
+ "expirationMonth" => 12,
28
+ "authMethod" => "3DS",
29
+ "3dsCryptogram" => "AgAAAAAABk4DWZ4C28yUQAAAAAA=",
30
+ "3dsEciIndicator" => "07"
31
+ }
32
+ }
33
+ decrypted = new_token.decrypt(@private_key)
34
+
35
+ assert_equal expected, decrypted
36
+ end
37
+
38
+ def test_decrypted_card
39
+ @token = JSON.parse(File.read(@fixtures + 'card.json'))
40
+ expected = {
41
+ "messageExpiration" => "1510319499834",
42
+ "paymentMethod" => "CARD",
43
+ "messageId" => "AH2EjtcMeg5mOCD9kUXWn6quP6AF6jOJeirO0EW40tPVzMMy_YAri8HZdJzqzQquC0w_dkvXhC41s2BN53HRD_kzgT4jxGeB4E9BI8OQCPw9GgWTXIAQb55Av77l6VCesYHIQre8Ij60",
44
+ "paymentMethodDetails" =>
45
+ {
46
+ "expirationYear" => 2022,
47
+ "expirationMonth" => 12,
48
+ "pan" => "4111111111111111"
49
+ }
50
+ }
51
+ decrypted = new_token.decrypt(@private_key)
52
+
53
+ assert_equal expected, decrypted
54
+ end
55
+
56
+ def test_wrong_signature
57
+ @token['signature'] = "MEQCIDxBoUCoFRGReLdZ/cABlSSRIKoOEFoU3e27c14vMZtfAiBtX3pGMEpnw6mSAbnagCCgHlCk3NcFwWYEyxIE6KGZVA\u003d\u003d"
58
+
59
+ assert_raises R2D2::SignatureInvalidError do
60
+ new_token.decrypt(@private_key)
61
+ end
62
+ end
63
+
64
+ def test_wrong_verification_key
65
+ @verification_keys = JSON.parse(File.read(@fixtures + "google_verification_key_production.json"))
66
+
67
+ assert_raises R2D2::SignatureInvalidError do
68
+ new_token.decrypt(@private_key)
69
+ end
70
+ end
71
+
72
+ def test_unknown_verification_key_version
73
+ @verification_keys['keys'][0]['protocolVersion'] = 'foo'
74
+
75
+ assert_raises R2D2::SignatureInvalidError do
76
+ new_token.decrypt(@private_key)
77
+ end
78
+ end
79
+
80
+ def test_multiple_verification_keys
81
+ production_keys = JSON.parse(File.read(@fixtures + "google_verification_key_production.json"))['keys']
82
+ @verification_keys = { 'keys' => production_keys + @verification_keys['keys'] }
83
+
84
+ assert new_token.decrypt(@private_key)
85
+ end
86
+
87
+ def test_expired_message
88
+ Timecop.freeze(Time.at(1510318760)) do
89
+ assert_raises R2D2::MessageExpiredError do
90
+ new_token.decrypt(@private_key)
91
+ end
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def new_token
98
+ R2D2::GooglePayToken.new(
99
+ @token,
100
+ recipient_id: @recipient_id,
101
+ verification_keys: @verification_keys
102
+ )
103
+ end
104
+ end
105
+ end
@@ -2,4 +2,5 @@ $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__)
2
2
  require "r2d2"
3
3
 
4
4
  require "minitest/autorun"
5
+ require "timecop"
5
6
  require "pry-byebug"
@@ -0,0 +1,37 @@
1
+ require "test_helper"
2
+
3
+ module R2D2
4
+ class TokenBuilderTest < Minitest::Test
5
+ def setup
6
+ @fixtures = __dir__ + "/fixtures/"
7
+ @recipient_id = 'merchant:12345678901234567890'
8
+ @verification_keys = JSON.parse(File.read(@fixtures + "ec_v1/google_verification_key_test.json"))
9
+ end
10
+
11
+ def test_builds_android_pay_token
12
+ token_attrs = JSON.parse(File.read(@fixtures + "token.json"))
13
+ assert_instance_of AndroidPayToken, R2D2.build_token(token_attrs)
14
+ end
15
+
16
+ def test_builds_google_pay_token
17
+ token_attrs = JSON.parse(File.read(@fixtures + "ec_v1/tokenized_card.json"))
18
+ assert_instance_of GooglePayToken, R2D2.build_token(token_attrs, recipient_id: @recipient_id, verification_keys: @verification_keys)
19
+ end
20
+
21
+ def test_building_token_raises_with_unknown_protocol_version
22
+ token_attrs = JSON.parse(File.read(@fixtures + "ec_v1/tokenized_card.json"))
23
+ token_attrs['protocolVersion'] = 'foo'
24
+
25
+ assert_raises ArgumentError do
26
+ R2D2.build_token(token_attrs, recipient_id: @recipient_id, verification_keys: @verification_keys)
27
+ end
28
+ end
29
+
30
+ def test_building_google_pay_token_raises_with_missing_arguments
31
+ token_attrs = JSON.parse(File.read(@fixtures + "ec_v1/tokenized_card.json"))
32
+ assert_raises ArgumentError do
33
+ R2D2.build_token(token_attrs)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,31 @@
1
+ require "test_helper"
2
+
3
+ module R2D2
4
+ class UtilTest < Minitest::Test
5
+ include Util
6
+
7
+ def setup
8
+ fixtures = __dir__ + "/fixtures/"
9
+ @token_attrs = JSON.parse(File.read(fixtures + "token.json"))
10
+ @private_key = File.read(fixtures + "private_key.pem")
11
+ @payment_token = R2D2::AndroidPayToken.new(@token_attrs)
12
+ @shared_secret = ['44a9715c18ebcb255af705f7332657420aca40604334a7d48a89baba18280a97']
13
+ end
14
+
15
+ def test_shared_secret
16
+ priv_key = OpenSSL::PKey::EC.new(@private_key)
17
+ assert_equal @shared_secret, generate_shared_secret(priv_key, @payment_token.ephemeral_public_key).unpack('H*')
18
+ end
19
+
20
+ def test_derive_hkdf_keys
21
+ hkdf_keys = derive_hkdf_keys(@payment_token.ephemeral_public_key, @shared_secret[0], 'Android')
22
+ assert_equal ["c7b2670dc0630edd0a9101dd5d70e4b2"], hkdf_keys[:symmetric_encryption_key].unpack('H*')
23
+ assert_equal ["d8976b95c980760d8ce3933994c6eda1"], hkdf_keys[:mac_key].unpack('H*')
24
+ end
25
+
26
+ def test_to_length_value
27
+ expected = "\x06\x00\x00\x00Google\x04\x00\x00\x00ECv1\x13\x01\x00\x00" + 'longstring-' * 25
28
+ assert_equal expected, to_length_value('Google', 'ECv1', 'longstring-' * 25)
29
+ end
30
+ end
31
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: r2d2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miki Rezentes
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2018-05-08 00:00:00.000000000 Z
12
+ date: 2018-06-08 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: hkdf
@@ -67,6 +67,20 @@ dependencies:
67
67
  - - "~>"
68
68
  - !ruby/object:Gem::Version
69
69
  version: '5.0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: timecop
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
70
84
  - !ruby/object:Gem::Dependency
71
85
  name: pry-byebug
72
86
  requirement: !ruby/object:Gem::Requirement
@@ -95,17 +109,27 @@ files:
95
109
  - README.md
96
110
  - Rakefile
97
111
  - lib/r2d2.rb
98
- - lib/r2d2/payment_token.rb
112
+ - lib/r2d2/android_pay_token.rb
113
+ - lib/r2d2/google_pay_token.rb
114
+ - lib/r2d2/util.rb
99
115
  - lib/r2d2/version.rb
100
116
  - r2d2.gemspec
117
+ - test/android_pay_token_test.rb
118
+ - test/fixtures/ec_v1/card.json
119
+ - test/fixtures/ec_v1/google_verification_key_production.json
120
+ - test/fixtures/ec_v1/google_verification_key_test.json
121
+ - test/fixtures/ec_v1/private_key.pem
122
+ - test/fixtures/ec_v1/tokenized_card.json
101
123
  - test/fixtures/private_key.pem
102
124
  - test/fixtures/public_key.pem
103
125
  - test/fixtures/token.json
126
+ - test/google_pay_token_test.rb
104
127
  - test/initial_dev/test_data.md
105
128
  - test/initial_dev/test_data_private_key.pem
106
129
  - test/initial_dev/test_data_token.json
107
- - test/payment_token_test.rb
108
130
  - test/test_helper.rb
131
+ - test/token_builder_test.rb
132
+ - test/util_test.rb
109
133
  homepage: https://github.com/spreedly/r2d2
110
134
  licenses: []
111
135
  metadata: {}
@@ -117,7 +141,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
117
141
  requirements:
118
142
  - - ">="
119
143
  - !ruby/object:Gem::Version
120
- version: 1.8.7
144
+ version: '2.2'
121
145
  required_rubygems_version: !ruby/object:Gem::Requirement
122
146
  requirements:
123
147
  - - ">="
@@ -125,16 +149,24 @@ required_rubygems_version: !ruby/object:Gem::Requirement
125
149
  version: '0'
126
150
  requirements: []
127
151
  rubyforge_project:
128
- rubygems_version: 2.6.11
152
+ rubygems_version: 2.7.5
129
153
  signing_key:
130
154
  specification_version: 4
131
155
  summary: Android Pay payment token decryption library
132
156
  test_files:
157
+ - test/android_pay_token_test.rb
158
+ - test/fixtures/ec_v1/card.json
159
+ - test/fixtures/ec_v1/google_verification_key_production.json
160
+ - test/fixtures/ec_v1/google_verification_key_test.json
161
+ - test/fixtures/ec_v1/private_key.pem
162
+ - test/fixtures/ec_v1/tokenized_card.json
133
163
  - test/fixtures/private_key.pem
134
164
  - test/fixtures/public_key.pem
135
165
  - test/fixtures/token.json
166
+ - test/google_pay_token_test.rb
136
167
  - test/initial_dev/test_data.md
137
168
  - test/initial_dev/test_data_private_key.pem
138
169
  - test/initial_dev/test_data_token.json
139
- - test/payment_token_test.rb
140
170
  - test/test_helper.rb
171
+ - test/token_builder_test.rb
172
+ - test/util_test.rb
@@ -1,82 +0,0 @@
1
- require 'openssl'
2
- require 'base64'
3
- require 'hkdf'
4
-
5
- module R2D2
6
- class PaymentToken
7
-
8
- attr_accessor :encrypted_message, :ephemeral_public_key, :tag
9
-
10
- class TagVerificationError < StandardError; end;
11
-
12
- def initialize(token_attrs)
13
- self.ephemeral_public_key = token_attrs["ephemeralPublicKey"]
14
- self.tag = token_attrs["tag"]
15
- self.encrypted_message = token_attrs["encryptedMessage"]
16
- end
17
-
18
- def decrypt(private_key_pem)
19
- digest = OpenSSL::Digest.new('sha256')
20
- private_key = OpenSSL::PKey::EC.new(private_key_pem)
21
-
22
- shared_secret = self.class.generate_shared_secret(private_key, ephemeral_public_key)
23
-
24
- # derive the symmetric_encryption_key and mac_key
25
- hkdf_keys = self.class.derive_hkdf_keys(ephemeral_public_key, shared_secret);
26
-
27
- # verify the tag is a valid value
28
- self.class.verify_mac(digest, hkdf_keys[:mac_key], encrypted_message, tag)
29
-
30
- self.class.decrypt_message(encrypted_message, hkdf_keys[:symmetric_encryption_key])
31
- end
32
-
33
- class << self
34
-
35
- def generate_shared_secret(private_key, ephemeral_public_key)
36
- ec = OpenSSL::PKey::EC.new('prime256v1')
37
- bn = OpenSSL::BN.new(Base64.decode64(ephemeral_public_key), 2)
38
- point = OpenSSL::PKey::EC::Point.new(ec.group, bn)
39
- private_key.dh_compute_key(point)
40
- end
41
-
42
- def derive_hkdf_keys(ephemeral_public_key, shared_secret)
43
- key_material = Base64.decode64(ephemeral_public_key) + shared_secret;
44
- hkdf = HKDF.new(key_material, :algorithm => 'SHA256', :info => 'Android')
45
- {
46
- :symmetric_encryption_key => hkdf.next_bytes(16),
47
- :mac_key => hkdf.next_bytes(16)
48
- }
49
- end
50
-
51
- def verify_mac(digest, mac_key, encrypted_message, tag)
52
- mac = OpenSSL::HMAC.digest(digest, mac_key, Base64.decode64(encrypted_message))
53
- raise TagVerificationError unless secure_compare(mac, Base64.decode64(tag))
54
- end
55
-
56
- def decrypt_message(encrypted_data, symmetric_key)
57
- decipher = OpenSSL::Cipher::AES128.new(:CTR)
58
- decipher.decrypt
59
- decipher.key = symmetric_key
60
- decipher.auth_data = ""
61
- decipher.update(Base64.decode64(encrypted_data)) + decipher.final
62
- end
63
-
64
- if defined?(FastSecureCompare)
65
- def secure_compare(a, b)
66
- FastSecureCompare.compare(a, b)
67
- end
68
- else
69
- # constant-time comparison algorithm to prevent timing attacks; borrowed from ActiveSupport::MessageVerifier
70
- def secure_compare(a, b)
71
- return false unless a.bytesize == b.bytesize
72
-
73
- l = a.unpack("C#{a.bytesize}")
74
-
75
- res = 0
76
- b.each_byte { |byte| res |= byte ^ l.shift }
77
- res == 0
78
- end
79
- end
80
- end
81
- end
82
- end
@@ -1,49 +0,0 @@
1
- require "test_helper"
2
-
3
- class R2D2::PaymentTokenTest < Minitest::Test
4
-
5
- def setup
6
- fixtures = File.dirname(__FILE__) + "/fixtures/"
7
- @token_attrs = JSON.parse(File.read(fixtures + "token.json"))
8
- @private_key = File.read(fixtures + "private_key.pem")
9
- @payment_token = R2D2::PaymentToken.new(@token_attrs)
10
- @shared_secret = ['44a9715c18ebcb255af705f7332657420aca40604334a7d48a89baba18280a97']
11
- @mac_key = ["d8976b95c980760d8ce3933994c6eda1"]
12
- @symmetric_encryption_key = ["c7b2670dc0630edd0a9101dd5d70e4b2"]
13
- end
14
-
15
- def test_initialize
16
- assert_equal @token_attrs["ephemeralPublicKey"], @payment_token.ephemeral_public_key
17
- assert_equal @token_attrs["tag"], @payment_token.tag
18
- assert_equal @token_attrs["encryptedMessage"], @payment_token.encrypted_message
19
- end
20
-
21
- def test_successful_decrypt
22
- payment_data = JSON.parse(@payment_token.decrypt( @private_key))
23
- assert_equal "4895370012003478", payment_data["dpan"]
24
- assert_equal 12, payment_data["expirationMonth"]
25
- assert_equal 2020, payment_data["expirationYear"]
26
- assert_equal "3DS", payment_data["authMethod"]
27
- assert_equal "AgAAAAAABk4DWZ4C28yUQAAAAAA=", payment_data["3dsCryptogram"]
28
- assert_equal "07", payment_data["3dsEciIndicator"]
29
- end
30
-
31
- def test_shared_secret
32
- priv_key = OpenSSL::PKey::EC.new(@private_key)
33
- assert_equal @shared_secret, R2D2::PaymentToken.generate_shared_secret(priv_key, @payment_token.ephemeral_public_key).unpack('H*')
34
- end
35
-
36
- def test_derive_hkdf_keys
37
- hkdf_keys = R2D2::PaymentToken.derive_hkdf_keys(@payment_token.ephemeral_public_key, @shared_secret[0])
38
- assert_equal hkdf_keys[:symmetric_encryption_key].unpack('H*'), @symmetric_encryption_key
39
- assert_equal hkdf_keys[:mac_key].unpack('H*'), @mac_key
40
- end
41
-
42
- def test_invalid_tag
43
- @payment_token.tag = "SomethingBogus"
44
- assert_raises R2D2::PaymentToken::TagVerificationError do
45
- JSON.parse(@payment_token.decrypt( @private_key))
46
- end
47
- end
48
-
49
- end