aliquot 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/aliquot/error.rb +16 -0
- data/lib/aliquot/validator.rb +184 -0
- data/lib/aliquot.rb +209 -0
- metadata +116 -0
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: []
|