aliquot 0.9.1 → 0.10.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 +4 -4
- data/lib/aliquot/payment.rb +141 -0
- data/lib/aliquot/validator.rb +1 -1
- data/lib/aliquot.rb +1 -207
- metadata +17 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 80203d2d0e3ce4ee09bc59e1e784ea432c1716573187c1fd8ff9822a59a48e48
|
4
|
+
data.tar.gz: 7b07ed3fcaad21506b32e3f9cd78f5424ac3ab9f71d920f2f36e32dd1fb21b31
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/aliquot/validator.rb
CHANGED
@@ -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.
|
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:
|
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:
|