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 +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: []
|