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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a5a6e9478205fe70ed71491c1988b4a67fc01009b095be55a7f4746dbbc342cd
4
- data.tar.gz: 8f2f91c7c87e730f7753970a5ec5ed2caecdef5f3095c87b76762c100a245012
3
+ metadata.gz: 80203d2d0e3ce4ee09bc59e1e784ea432c1716573187c1fd8ff9822a59a48e48
4
+ data.tar.gz: 7b07ed3fcaad21506b32e3f9cd78f5424ac3ab9f71d920f2f36e32dd1fb21b31
5
5
  SHA512:
6
- metadata.gz: 8df3da2fe45775b50d714059419dd5720ed9a0d929daf14463a1b4872d9505e264dd700a11adf1b738a416d8edda93b14cfa97471242f47a744498e4b22c037c
7
- data.tar.gz: 547802ae6e59db8c65dd40d9bada6142d89f98ded4b63b9ea62f22765d303b88ec1fff51f33b887b8b4c805e4ae14afbcd40a5afa4c548132bc21cd9e1677c2c
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
@@ -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.9.1
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: 2018-10-09 00:00:00.000000000 Z
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: