aliquot 0.9.1 → 0.10.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
2
  SHA256:
3
- metadata.gz: a5a6e9478205fe70ed71491c1988b4a67fc01009b095be55a7f4746dbbc342cd
4
- data.tar.gz: 8f2f91c7c87e730f7753970a5ec5ed2caecdef5f3095c87b76762c100a245012
3
+ metadata.gz: 80203d2d0e3ce4ee09bc59e1e784ea432c1716573187c1fd8ff9822a59a48e48
4
+ data.tar.gz: 7b07ed3fcaad21506b32e3f9cd78f5424ac3ab9f71d920f2f36e32dd1fb21b31
5
5
  SHA512:
6
- metadata.gz: 8df3da2fe45775b50d714059419dd5720ed9a0d929daf14463a1b4872d9505e264dd700a11adf1b738a416d8edda93b14cfa97471242f47a744498e4b22c037c
7
- data.tar.gz: 547802ae6e59db8c65dd40d9bada6142d89f98ded4b63b9ea62f22765d303b88ec1fff51f33b887b8b4c805e4ae14afbcd40a5afa4c548132bc21cd9e1677c2c
6
+ metadata.gz: b4c7699b95ea4af8ce4b30cebebdd530acd3139835c7cd3833953498181ee737bf3db4737f36b759eb58c085770e5d6b104000f7bcaa7e2f74a2a8074f07ffec
7
+ data.tar.gz: 3b3ade2e3c20127c49e33f5957cd502ba6f6e2b0f4388c2f0f924b0ed5dc1830bd04618949b103683ff74b105fd897499330409a9f5e97c1f43cd704d0ddaaf1
@@ -0,0 +1,141 @@
1
+ require 'json'
2
+ require 'base64'
3
+ require 'hkdf'
4
+ require 'openssl'
5
+
6
+ require 'aliquot/error'
7
+ require 'aliquot/validator'
8
+
9
+ module Aliquot
10
+ ##
11
+ # A Payment represents a single payment using Google Pay.
12
+ # It is used to verify/decrypt the supplied token by using the shared secret,
13
+ # thus avoiding having knowledge of merchant primary keys.
14
+ class Payment
15
+ ##
16
+ # Parameters:
17
+ # token_string:: Google Pay token (JSON string)
18
+ # shared_secret:: Base64 encoded shared secret
19
+ # merchant_id:: Google Pay merchant ID ("merchant:<SOMETHING>")
20
+ # signing_keys:: Signing keys fetched from Google
21
+ def initialize(token_string, shared_secret, merchant_id,
22
+ signing_keys: ENV['GOOGLE_SIGNING_KEYS'])
23
+
24
+ validation = Aliquot::Validator::Token.new(JSON.parse(token_string))
25
+ validation.validate
26
+
27
+ @token = validation.output
28
+
29
+ @shared_secret = shared_secret
30
+ @merchant_id = merchant_id
31
+ @signing_keys = signing_keys
32
+ end
33
+
34
+ ##
35
+ # Validate and decrypt the token.
36
+ def process
37
+ unless valid_protocol_version?
38
+ raise Error, 'only ECv1 protocolVersion is supported'
39
+ end
40
+
41
+ raise InvalidSignatureError unless valid_signature?
42
+
43
+ validator = Aliquot::Validator::SignedMessage.new(JSON.parse(@token[:signedMessage]))
44
+ validator.validate
45
+ signed_message = validator.output
46
+
47
+ aes_key, mac_key = derive_keys(signed_message[:ephemeralPublicKey], @shared_secret, 'Google')
48
+
49
+ unless self.class.valid_mac?(mac_key, signed_message[:encryptedMessage], signed_message[:tag])
50
+ raise InvalidMacError
51
+ end
52
+
53
+ @message = JSON.parse(self.class.decrypt(aes_key, signed_message[:encryptedMessage]))
54
+
55
+ message_validator = Aliquot::Validator::EncryptedMessageValidator.new(@message)
56
+ message_validator.validate
57
+
58
+ # Output is hashed with symbolized keys.
59
+ @message = message_validator.output
60
+
61
+ raise ExpiredException if expired?
62
+
63
+ @message
64
+ end
65
+
66
+ def protocol_version
67
+ @token[:protocolVersion]
68
+ end
69
+
70
+ def valid_protocol_version?
71
+ protocol_version == 'ECv1'
72
+ end
73
+
74
+ ##
75
+ # Check if the token is expired, according to the messageExpiration included
76
+ # in the token.
77
+ def expired?
78
+ @message[:messageExpiration].to_f / 1000.0 <= Time.now.to_f
79
+ end
80
+
81
+ def valid_signature?
82
+ signed_string = ['Google', @merchant_id, protocol_version, @token[:signedMessage]].map do |str|
83
+ [str.length].pack('V') + str
84
+ end.join
85
+
86
+ keys = JSON.parse(@signing_keys)['keys']
87
+ # Check if signature was performed with any possible key.
88
+ keys.map do |key|
89
+ next if key['protocolVersion'] != protocol_version
90
+
91
+ ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
92
+ ec.verify(OpenSSL::Digest::SHA256.new, Base64.strict_decode64(@token[:signature]), signed_string)
93
+ end.any?
94
+ end
95
+
96
+ def self.decrypt(key, encrypted)
97
+ c = OpenSSL::Cipher::AES128.new(:CTR)
98
+ c.key = key
99
+ c.decrypt
100
+
101
+ c.update(Base64.strict_decode64(encrypted)) + c.final
102
+ end
103
+
104
+ def self.valid_mac?(mac_key, data, tag)
105
+ digest = OpenSSL::Digest::SHA256.new
106
+ mac = OpenSSL::HMAC.digest(digest, mac_key, Base64.strict_decode64(data))
107
+
108
+ compare(Base64.strict_encode64(mac), tag)
109
+ end
110
+
111
+ def self.compare(a, b)
112
+ return false unless a.length == b.length
113
+
114
+ diffs = 0
115
+
116
+ ys = b.unpack('C*')
117
+
118
+ a.each_byte do |x|
119
+ diffs |= x ^ ys.shift
120
+ end
121
+
122
+ diffs.zero?
123
+ end
124
+
125
+ private
126
+
127
+ # Keys are derived according to the Google Pay specification.
128
+ def derive_keys(ephemeral_public_key, shared_secret, info)
129
+ input_keying_material = Base64.strict_decode64(ephemeral_public_key) + Base64.strict_decode64(shared_secret)
130
+
131
+ if OpenSSL.const_defined?(:KDF) && OpenSSL::KDF.respond_to?(:hkdf)
132
+ h = OpenSSL::Digest::SHA256.new
133
+ key_bytes = OpenSSL::KDF.hkdf(input_keying_material, hash: h, salt: '', length: 32, info: info)
134
+ else
135
+ key_bytes = HKDF.new(input_keying_material, algorithm: 'SHA256', info: info).next_bytes(32)
136
+ end
137
+
138
+ [key_bytes[0..15], key_bytes[16..32]]
139
+ end
140
+ end
141
+ end
@@ -83,7 +83,7 @@ module Aliquot
83
83
  # DRY-Validation schema for signedMessage component Google Pay token
84
84
  SignedMessageSchema = Dry::Validation.Schema(BaseSchema) do
85
85
  required(:encryptedMessage).filled(:str?, :base64?)
86
- required(:ephemeralPublicKey).filled(:str?, :base64?)
86
+ required(:ephemeralPublicKey).filled(:str?, :base64?).value(size?: 44)
87
87
  required(:tag).filled(:str?, :base64?)
88
88
  end
89
89
 
data/lib/aliquot.rb CHANGED
@@ -1,209 +1,3 @@
1
- require 'json'
2
- require 'base64'
3
- require 'excon'
4
- require 'hkdf'
5
-
6
1
  require 'aliquot/validator'
7
2
  require 'aliquot/error'
8
-
9
- $key_updater_semaphore = Mutex.new
10
- $key_updater_thread = nil
11
-
12
- module Aliquot
13
- ##
14
- # Constant-time comparison function
15
- def self.compare(a, b)
16
- err = 0
17
-
18
- y = b.unpack('C*')
19
-
20
- a.each_byte do |x|
21
- err |= x ^ y.shift
22
- end
23
-
24
- err.zero?
25
- end
26
-
27
- ##
28
- # Keys used for signing in production
29
- SIGNING_KEY_URL = 'https://payments.developers.google.com/paymentmethodtoken/keys.json'.freeze
30
-
31
- ##
32
- # Keys used for signing in a testing environment
33
- TEST_SIGNING_KEY_URL = 'https://payments.developers.google.com/paymentmethodtoken/test/keys.json'.freeze
34
-
35
- ##
36
- # Start a thread that keeps the Google signing keys updated.
37
- def self.start_key_updater(logger)
38
- source = if ENV['ENVIRONMENT'] == 'production'
39
- SIGNING_KEY_URL
40
- else
41
- TEST_SIGNING_KEY_URL
42
- end
43
-
44
- $key_updater_semaphore.synchronize do
45
- # Another thread might have been waiting for on the mutex
46
- break unless $key_updater_thread.nil?
47
-
48
- new_thread = Thread.new do
49
- loop do
50
- begin
51
- timeout = 0
52
-
53
- conn = Excon.new(source)
54
- resp = conn.get
55
-
56
- raise 'Unable to update keys: ' + resp.data[:status_line] unless resp.status == 200
57
- cache_control = resp.headers['Cache-Control'].split(/,\s*/)
58
- h = cache_control.map { |x| /\Amax-age=(?<timeout>\d+)\z/ =~ x; timeout }.compact
59
-
60
- timeout = h.first.to_i if h.length == 1
61
- timeout = 86400 if timeout.nil? || !timeout.positive?
62
-
63
- Thread.current.thread_variable_set('keys', resp.body)
64
-
65
- # Supposedly recommended by Tink library
66
- sleep_time = timeout / 2
67
-
68
- logger.info('Updated Google signing keys. Sleeping for: ' + (sleep_time / 86400.0).to_s + ' days')
69
-
70
- sleep sleep_time
71
- rescue Interrupt => e
72
- # When interrupted
73
- logger.fatal('Quitting: ' + e.message)
74
- return
75
- rescue => e
76
- # Don't retry excessively.
77
- logger.error('Exception updating Google signing keys: ' + e.message)
78
- sleep 1
79
- end
80
- end
81
- end
82
-
83
- sleep 0.2 while new_thread.thread_variable_get('keys').nil?
84
- # Body has now been set.
85
- # Let other clients through.
86
- $key_updater_thread = new_thread
87
- end
88
- end
89
-
90
- ##
91
- # A Payment represents a single payment using Google Pay.
92
- # It is used to verify/decrypt the supplied token by using the shared secret,
93
- # thus avoiding having knowledge of merchant primary keys.
94
- class Payment
95
- ##
96
- # Parameters:
97
- # token_string:: Google Pay token (JSON string)
98
- # shared_secret:: Base64 encoded shared secret
99
- # merchant_id:: Google Pay merchant ID ("merchant:<SOMETHING>")
100
- # logger:: The logger to use. Default: Logger.new($stdout)
101
- # signing_keys:: Formatted list of signing keys used to sign token contents.
102
- # Otherwise a thread continuously updating google signing
103
- # keys will be started.
104
- def initialize(token_string, shared_secret, merchant_id,
105
- logger: Logger.new($stdout), signing_keys: ENV['GOOGLE_SIGNING_KEYS'])
106
- Aliquot.start_key_updater(logger) if $key_updater_thread.nil? && signing_keys.nil?
107
- @signing_keys = signing_keys
108
-
109
- @shared_secret = shared_secret
110
- @merchant_id = merchant_id
111
- @token_string = token_string
112
- end
113
-
114
- ##
115
- # Validate and decrypt the token.
116
- def process
117
- @token = JSON.parse(@token_string)
118
- validate(Aliquot::Validator::Token, @token)
119
-
120
- @protocol_version = @token['protocolVersion']
121
-
122
- raise Error, 'only ECv1 protocolVersion is supported' unless @protocol_version == 'ECv1'
123
-
124
- raise InvalidSignatureError unless valid_signature?(@token['signedMessage'],
125
- @token['signature'])
126
-
127
- @signed_message = JSON.parse(@token['signedMessage'])
128
- validate(Aliquot::Validator::SignedMessage, @signed_message)
129
-
130
- aes_key, mac_key = derive_keys(@signed_message['ephemeralPublicKey'],
131
- @shared_secret,
132
- 'Google')
133
-
134
- raise InvalidMacError unless valid_mac?(mac_key,
135
- @signed_message['encryptedMessage'],
136
- @signed_message['tag'])
137
-
138
- @message = decrypt(aes_key, @signed_message['encryptedMessage'])
139
-
140
- validate(Aliquot::Validator::EncryptedMessageValidator, @message)
141
-
142
- raise ExpiredException if expired?
143
-
144
- @message
145
- end
146
-
147
- ##
148
- # Check if the token is expired, according to the messageExpiration included
149
- # in the token.
150
- def expired?
151
- @message['messageExpiration'].to_f / 1000.0 <= Time.now.to_f
152
- end
153
-
154
- private
155
-
156
- def validate(klass, data)
157
- validator = klass.new(data)
158
- validator.validate
159
- end
160
-
161
- def derive_keys(ephemeral_public_key, shared_secret, info)
162
- ikm = Base64.strict_decode64(ephemeral_public_key) +
163
- Base64.strict_decode64(shared_secret)
164
- hbytes = HKDF.new(ikm, algorithm: 'SHA256', info: info).next_bytes(32)
165
-
166
- [hbytes[0..15], hbytes[16..32]]
167
- end
168
-
169
- def decrypt(key, encrypted)
170
- c = OpenSSL::Cipher::AES128.new(:CTR)
171
- c.key = key
172
- c.decrypt
173
- plain = c.update(Base64.strict_decode64(encrypted)) + c.final
174
- JSON.parse(plain)
175
- end
176
-
177
- def valid_signature?(message, signature)
178
- # Generate the string that was signed.
179
- signed_string = ['Google', @merchant_id, @protocol_version, message].map do |str|
180
- [str.length].pack('V') + str
181
- end.join
182
-
183
- keys = JSON.parse(signing_keys)['keys']
184
- # Check if signature was performed with any possible key.
185
- keys.map do |e|
186
- next if e['protocolVersion'] != @protocol_version
187
-
188
- ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(e['keyValue']))
189
- d = OpenSSL::Digest::SHA256.new
190
- ec.verify(d, Base64.strict_decode64(signature), signed_string)
191
- end.any?
192
- end
193
-
194
- def valid_mac?(mac_key, data, tag)
195
- d = OpenSSL::Digest::SHA256.new
196
- mac = OpenSSL::HMAC.digest(d, mac_key, Base64.strict_decode64(data))
197
- mac = Base64.strict_encode64(mac)
198
-
199
- return false if mac.length != tag.length
200
-
201
- Aliquot.compare(mac, tag)
202
- end
203
-
204
- def signing_keys
205
- # Prefer static signing keys, otherwise fetch from updating thread.
206
- @signing_keys || $key_updater_thread.thread_variable_get('keys')
207
- end
208
- end
209
- end
3
+ require 'aliquot/payment'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aliquot
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clearhaus
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-10-09 00:00:00.000000000 Z
11
+ date: 2019-01-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: pry
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: rspec
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -88,6 +102,7 @@ extra_rdoc_files: []
88
102
  files:
89
103
  - lib/aliquot.rb
90
104
  - lib/aliquot/error.rb
105
+ - lib/aliquot/payment.rb
91
106
  - lib/aliquot/validator.rb
92
107
  homepage: https://github.com/clearhaus/aliquot
93
108
  licenses: