aliquot 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3a04779c8a107c40976456dfe5ef3192ab6e8b31ed215f542edd7ef8d0344e37
4
+ data.tar.gz: 0c721e2aa5a54d9b4999dbd7e179aea62cf54bb93a077322e186299e5d079dae
5
+ SHA512:
6
+ metadata.gz: d0eb73c56e32bda0d998b8206beec72a4ffef47127da0b149d5a052c7fdbe64a62e89cbb74e4502db29d6c3c05da1bba9197ccddb9b512410a65c0f39483bd68
7
+ data.tar.gz: 4d778545daa1974f66f27502c5c9e851715b189bf7e0e2c06de3a2aedda258f5b8b831e77994e82cfeadb31e798a2507046248cc309853ee44a95ae09eb06b38
@@ -0,0 +1,16 @@
1
+ module Aliquot
2
+ # Base class for all errors thrown in Aliquot
3
+ class Error < StandardError; end
4
+
5
+ # Thrown if the token is expired
6
+ class ExpiredException < Error; end
7
+
8
+ # Thrown if the signature is invalid
9
+ class InvalidSignatureError < Error; end
10
+
11
+ # Thrown if the MAC is invalid
12
+ class InvalidMacError < Error; end
13
+
14
+ # Thrown if there was an error validating the input data
15
+ class ValidationError < Error; end
16
+ end
@@ -0,0 +1,184 @@
1
+ require 'aliquot/error'
2
+
3
+ require 'base64'
4
+ require 'dry-validation'
5
+ require 'json'
6
+ require 'openssl'
7
+
8
+ module Aliquot
9
+ module Validator
10
+ # Verified according to:
11
+ # https://developers.google.com/pay/api/web/guides/resources/payment-data-cryptography#payment-method-token-structure
12
+ module Predicates
13
+ include Dry::Logic::Predicates
14
+
15
+ CUSTOM_PREDICATE_ERRORS = {
16
+ base64?: 'must be Base64',
17
+ pan?: 'must be a pan',
18
+ ec_public_key?: 'must be an EC public key',
19
+ eci?: 'must be an ECI',
20
+ json_string?: 'must be valid JSON',
21
+ integer_string?: 'must be string encoded integer',
22
+ month?: 'must be a month (1..12)',
23
+ year?: 'must be a year (2000..3000)',
24
+
25
+ authMethodCryptogram3DS: 'authMethod CRYPTOGRAM_3DS requires eciIndicator',
26
+ authMethodCard: 'eciIndicator/cryptogram must be omitted when PAN_ONLY',
27
+ }.freeze
28
+
29
+ # Support Ruby 2.3, but use the faster #match? when available.
30
+ match_b = ''.respond_to?(:match?) ? ->(s, re) { s.match?(re) } : ->(s, re) { !!(s =~ re) }
31
+
32
+ def self.to_bool(lbd)
33
+ lbd.call
34
+ true
35
+ rescue
36
+ false
37
+ end
38
+
39
+ predicate(:base64?) do |x|
40
+ str?(x) &&
41
+ match_b.call(x, /\A[=A-Za-z0-9+\/]*\z/) && # allowable chars
42
+ x.length.remainder(4).zero? && # multiple of 4
43
+ !match_b.call(x, /=[^$=]/) && # may only end with ='s
44
+ !match_b.call(x, /===/) # at most 2 ='s
45
+ end
46
+
47
+ # We should figure out how strict we should be. Hopefully we can discard
48
+ # the above Base64? predicate and use the following simpler one:
49
+ #predicate(:strict_base64?) { |x| !!Base64.strict_decode64(x) rescue false }
50
+
51
+ predicate(:pan?) { |x| match_b.call(x, /\A[1-9][0-9]{11,18}\z/) }
52
+
53
+ predicate(:eci?) { |x| str?(x) && match_b.call(x, /\A\d{1,2}\z/) }
54
+
55
+ predicate(:ec_public_key?) { |x| base64?(x) && OpenSSL::PKey::EC.new(Base64.decode64(x)).check_key rescue false }
56
+
57
+ predicate(:json_string?) { |x| !!JSON.parse(x) rescue false }
58
+
59
+ predicate(:integer_string?) { |x| str?(x) && match_b.call(x, /\A\d+\z/) }
60
+
61
+ predicate(:month?) { |x| x.between?(1, 12) }
62
+
63
+ predicate(:year?) { |x| x.between?(2000, 3000) }
64
+ end
65
+
66
+ # Base for DRY-Validation schemas used in Aliquot.
67
+ class BaseSchema < Dry::Validation::Schema::JSON
68
+ predicates(Predicates)
69
+ def self.messages
70
+ super.merge(en: { errors: Predicates::CUSTOM_PREDICATE_ERRORS })
71
+ end
72
+ end
73
+
74
+ # DRY-Validation schema for Google Pay token
75
+ TokenSchema = Dry::Validation.Schema(BaseSchema) do
76
+ required(:signature).filled(:str?, :base64?)
77
+
78
+ # Currently supposed to be ECv1, but may evolve.
79
+ required(:protocolVersion).filled(:str?)
80
+ required(:signedMessage).filled(:str?, :json_string?)
81
+ end
82
+
83
+ # DRY-Validation schema for signedMessage component Google Pay token
84
+ SignedMessageSchema = Dry::Validation.Schema(BaseSchema) do
85
+ required(:encryptedMessage).filled(:str?, :base64?)
86
+ required(:ephemeralPublicKey).filled(:str?, :base64?)
87
+ required(:tag).filled(:str?, :base64?)
88
+ end
89
+
90
+ # DRY-Validation schema for paymentMethodDetails component Google Pay token
91
+ PaymentMethodDetailsSchema = Dry::Validation.Schema(BaseSchema) do
92
+ required(:pan).filled(:integer_string?, :pan?)
93
+ required(:expirationMonth).filled(:int?, :month?)
94
+ required(:expirationYear).filled(:int?, :year?)
95
+ required(:authMethod).filled(:str?, included_in?: %w[PAN_ONLY CRYPTOGRAM_3DS])
96
+
97
+ optional(:cryptogram).filled(:str?)
98
+ optional(:eciIndicator).filled(:str?, :eci?)
99
+
100
+ rule('when authMethod is CRYPTOGRAM_3DS, cryptogram': %i[authMethod cryptogram]) do |method, cryptogram|
101
+ method.eql?('CRYPTOGRAM_3DS') > cryptogram.filled?
102
+ end
103
+
104
+ rule('when authMethod is PAN_ONLY, eciIndicator': %i[authMethod eciIndicator]) do |method, eci|
105
+ method.eql?('PAN_ONLY').then(eci.none?)
106
+ end
107
+
108
+ rule('when authMethod is PAN_ONLY, cryptogram': %i[authMethod cryptogram]) do |method, cryptogram|
109
+ method.eql?('PAN_ONLY').then(cryptogram.none?)
110
+ end
111
+ end
112
+
113
+ # DRY-Validation schema for encryptedMessage component Google Pay token
114
+ EncryptedMessageSchema = Dry::Validation.Schema(BaseSchema) do
115
+ required(:messageExpiration).filled(:str?, :integer_string?)
116
+ required(:messageId).filled(:str?)
117
+ required(:paymentMethod).filled(:str?, eql?: 'CARD')
118
+ required(:paymentMethodDetails).schema(PaymentMethodDetailsSchema)
119
+ end
120
+
121
+ module InstanceMethods
122
+ attr_reader :output
123
+
124
+ def validate
125
+ @validation ||= @schema.call(@input)
126
+ @output = @validation.output
127
+ return true if @validation.success?
128
+ raise Aliquot::ValidationError, "validation error: #{errors_formatted}"
129
+ end
130
+
131
+ def valid?
132
+ validate
133
+ rescue
134
+ false
135
+ end
136
+
137
+ def errors
138
+ valid? unless @validation
139
+
140
+ @validation.errors
141
+ end
142
+
143
+ def errors_formatted(node = [errors])
144
+ node.pop.flat_map do |key, value|
145
+ if value.is_a?(Array)
146
+ value.map { |error| "#{(node + [key]).join('.')} #{error}" }
147
+ else
148
+ errors_formatted(node + [key, value])
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ # Class for validating a Google Pay token
155
+ class Token
156
+ include InstanceMethods
157
+ class Error < ::Aliquot::Error; end
158
+ def initialize(input)
159
+ @input = input
160
+ @schema = TokenSchema
161
+ end
162
+ end
163
+
164
+ # Class for validating the SignedMessage component of a Google Pay token
165
+ class SignedMessage
166
+ include InstanceMethods
167
+ class Error < ::Aliquot::Error; end
168
+ def initialize(input)
169
+ @input = input
170
+ @schema = SignedMessageSchema
171
+ end
172
+ end
173
+
174
+ # Class for validating the encryptedMessage component of a Google Pay token
175
+ class EncryptedMessageValidator
176
+ include InstanceMethods
177
+ class Error < ::Aliquot::Error; end
178
+ def initialize(input)
179
+ @input = input
180
+ @schema = EncryptedMessageSchema
181
+ end
182
+ end
183
+ end
184
+ end
data/lib/aliquot.rb ADDED
@@ -0,0 +1,209 @@
1
+ require 'json'
2
+ require 'base64'
3
+ require 'excon'
4
+ require 'hkdf'
5
+
6
+ require 'aliquot/validator'
7
+ 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: nil)
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
metadata ADDED
@@ -0,0 +1,116 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: aliquot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Clearhaus
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-10-03 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: dry-validation
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: excon
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: hkdf
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: aliquot-pay
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3'
83
+ description:
84
+ email: hello@clearhaus.com
85
+ executables: []
86
+ extensions: []
87
+ extra_rdoc_files: []
88
+ files:
89
+ - lib/aliquot.rb
90
+ - lib/aliquot/error.rb
91
+ - lib/aliquot/validator.rb
92
+ homepage: https://github.com/clearhaus/aliquot
93
+ licenses:
94
+ - MIT
95
+ metadata: {}
96
+ post_install_message:
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubyforge_project:
112
+ rubygems_version: 2.7.7
113
+ signing_key:
114
+ specification_version: 4
115
+ summary: Validates Google Pay tokens
116
+ test_files: []