r2d2 0.1.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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