aliquot 0.9.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 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: []