aliquot 0.11.0 → 0.12.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 +149 -63
- data/lib/aliquot/validator.rb +32 -1
- metadata +10 -10
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a327bbbe5f475b924454c120979cb3bc965c5363c2740366a9f1abea19a59707
|
4
|
+
data.tar.gz: 044dd4f0c2daa2d72685133fbd27408247281449276c0e36cba1141778d98176
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: f25a55664501aa93322675512975d3471c4586112855d2d30f32b0f61fcad6240fdd3750d36e418feaa415f3a49ee752271f2d7c1dc5fe28cd6615fc5d16811b
|
7
|
+
data.tar.gz: e48a4d748adc053caa2e8a7770d5ef580fee0867497fa2d98dfac8cfc202a8183e3e8c900b56bac1cd121a95fadc4d8699511442263e93076d10e95ce53407af
|
data/lib/aliquot/payment.rb
CHANGED
@@ -12,6 +12,7 @@ module Aliquot
|
|
12
12
|
# It is used to verify/decrypt the supplied token by using the shared secret,
|
13
13
|
# thus avoiding having knowledge of merchant primary keys.
|
14
14
|
class Payment
|
15
|
+
SUPPORTED_PROTOCOL_VERSIONS = %w[ECv1 ECv2].freeze
|
15
16
|
##
|
16
17
|
# Parameters:
|
17
18
|
# token_string:: Google Pay token (JSON string)
|
@@ -39,40 +40,37 @@ module Aliquot
|
|
39
40
|
# Validate and decrypt the token.
|
40
41
|
def process
|
41
42
|
unless valid_protocol_version?
|
42
|
-
raise Error,
|
43
|
+
raise Error, "supported protocol versions are #{SUPPORTED_PROTOCOL_VERSIONS.join(', ')}"
|
44
|
+
end
|
45
|
+
|
46
|
+
if protocol_version == 'ECv2'
|
47
|
+
@intermediate_key = validate_intermediate_key
|
48
|
+
raise InvalidSignatureError, 'intermediate certificate expired' if intermediate_key_expired?
|
43
49
|
end
|
44
50
|
|
45
51
|
check_shared_secret
|
46
52
|
|
47
|
-
|
53
|
+
check_signature
|
48
54
|
|
49
|
-
|
50
|
-
validator.validate
|
51
|
-
signed_message = validator.output
|
55
|
+
@signed_message = validate_signed_message
|
52
56
|
|
53
57
|
begin
|
54
|
-
aes_key, mac_key = derive_keys(signed_message[:ephemeralPublicKey], @shared_secret, 'Google')
|
58
|
+
aes_key, mac_key = derive_keys(@signed_message[:ephemeralPublicKey], @shared_secret, 'Google')
|
55
59
|
rescue => e
|
56
60
|
raise KeyDerivationError, "unable to derive keys, #{e.message}"
|
57
61
|
end
|
58
62
|
|
59
|
-
unless
|
60
|
-
raise InvalidMacError
|
61
|
-
end
|
63
|
+
raise InvalidMacError unless valid_mac?(mac_key)
|
62
64
|
|
63
65
|
begin
|
64
|
-
@message = JSON.parse(
|
66
|
+
@message = JSON.parse(decrypt(aes_key, @signed_message[:encryptedMessage]))
|
65
67
|
rescue JSON::JSONError => e
|
66
68
|
raise InputError, "encryptedMessage JSON invalid, #{e.message}"
|
67
69
|
rescue => e
|
68
70
|
raise DecryptionError, "decryption failed, #{e.message}"
|
69
71
|
end
|
70
72
|
|
71
|
-
|
72
|
-
message_validator.validate
|
73
|
-
|
74
|
-
# Output is hashed with symbolized keys.
|
75
|
-
@message = message_validator.output
|
73
|
+
@message = validate_message
|
76
74
|
|
77
75
|
raise TokenExpiredError if expired?
|
78
76
|
|
@@ -84,47 +82,161 @@ module Aliquot
|
|
84
82
|
end
|
85
83
|
|
86
84
|
def valid_protocol_version?
|
87
|
-
protocol_version
|
85
|
+
SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
|
88
86
|
end
|
89
87
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
88
|
+
def validate_intermediate_key
|
89
|
+
# Valid JSON as it has been checked by Token Validator.
|
90
|
+
intermediate_key = JSON.parse(@token[:intermediateSigningKey][:signedKey])
|
91
|
+
|
92
|
+
validator = Aliquot::Validator::SignedKeyValidator.new(intermediate_key)
|
93
|
+
validator.validate
|
94
|
+
|
95
|
+
validator.output
|
96
|
+
end
|
97
|
+
|
98
|
+
def intermediate_key_expired?
|
99
|
+
cur_millis = (Time.now.to_f * 1000).round
|
100
|
+
@intermediate_key[:keyExpiration].to_i < cur_millis
|
95
101
|
end
|
96
102
|
|
97
|
-
def
|
98
|
-
|
103
|
+
def check_shared_secret
|
104
|
+
begin
|
105
|
+
decoded = Base64.strict_decode64(@shared_secret)
|
106
|
+
rescue
|
107
|
+
raise InvalidSharedSecretError, 'shared_secret must be base64'
|
108
|
+
end
|
109
|
+
|
110
|
+
raise InvalidSharedSecretError, 'shared_secret must be 32 bytes when base64 decoded' unless decoded.length == 32
|
111
|
+
end
|
112
|
+
|
113
|
+
def check_signature
|
114
|
+
signed_string_message = ['Google', @merchant_id, protocol_version, @token[:signedMessage]].map do |str|
|
99
115
|
[str.length].pack('V') + str
|
100
116
|
end.join
|
117
|
+
message_signature = Base64.strict_decode64(@token[:signature])
|
118
|
+
|
119
|
+
root_signing_keys = root_keys
|
120
|
+
|
121
|
+
case protocol_version
|
122
|
+
when 'ECv1'
|
123
|
+
# Check if signature was performed directly with any possible key.
|
124
|
+
success =
|
125
|
+
root_signing_keys.map do |key|
|
126
|
+
key.verify(new_digest, message_signature, signed_string_message)
|
127
|
+
end.any?
|
128
|
+
|
129
|
+
raise InvalidSignatureError unless success
|
130
|
+
when 'ECv2'
|
131
|
+
signed_key_signature = ['Google', 'ECv2', @token[:intermediateSigningKey][:signedKey]].map do |str|
|
132
|
+
[str.length].pack('V') + str
|
133
|
+
end.join
|
134
|
+
|
135
|
+
# Check that the intermediate key signed the message
|
136
|
+
pkey = OpenSSL::PKey::EC.new(Base64.strict_decode64(@intermediate_key[:keyValue]))
|
137
|
+
raise InvalidSignatureError, 'intermediate did not sign message' unless pkey.verify(new_digest, message_signature, signed_string_message)
|
138
|
+
|
139
|
+
intermediate_signatures = @token[:intermediateSigningKey][:signatures]
|
140
|
+
|
141
|
+
# Check that a root signing key signed the intermediate
|
142
|
+
success = valid_intermediate_key_signatures?(
|
143
|
+
root_signing_keys,
|
144
|
+
intermediate_signatures,
|
145
|
+
signed_key_signature
|
146
|
+
)
|
147
|
+
|
148
|
+
raise InvalidSignatureError, 'intermediate not signed' unless success
|
149
|
+
end
|
150
|
+
rescue OpenSSL::PKey::PKeyError => e
|
151
|
+
# Catches problems with verifying signature. Can be caused by signature
|
152
|
+
# being valid ASN1 but having invalid structure.
|
153
|
+
raise InvalidSignatureError, e.message
|
154
|
+
end
|
155
|
+
|
156
|
+
def root_keys
|
157
|
+
root_signing_keys = JSON.parse(@signing_keys)['keys'].select do |key|
|
158
|
+
key['protocolVersion'] == protocol_version
|
159
|
+
end
|
160
|
+
|
161
|
+
root_signing_keys.map! do |key|
|
162
|
+
OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
def valid_intermediate_key_signatures?(signing_keys, signatures, signed)
|
167
|
+
signing_keys.product(signatures).each do |key, sig|
|
168
|
+
return true if key.verify(new_digest, Base64.strict_decode64(sig), signed)
|
169
|
+
end
|
170
|
+
false
|
171
|
+
end
|
172
|
+
|
173
|
+
def validate_signed_message
|
174
|
+
signed_message = @token[:signedMessage]
|
175
|
+
validator = Aliquot::Validator::SignedMessage.new(JSON.parse(signed_message))
|
176
|
+
validator.validate
|
177
|
+
validator.output
|
178
|
+
end
|
179
|
+
|
180
|
+
# Keys are derived according to the Google Pay specification.
|
181
|
+
def derive_keys(ephemeral_public_key, shared_secret, info)
|
182
|
+
input_keying_material = Base64.strict_decode64(ephemeral_public_key) + Base64.strict_decode64(shared_secret)
|
183
|
+
|
184
|
+
key_len = new_cipher.key_len
|
101
185
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
186
|
+
key_bytes = if OpenSSL.const_defined?(:KDF) && OpenSSL::KDF.respond_to?(:hkdf)
|
187
|
+
OpenSSL::KDF.hkdf(input_keying_material, hash: new_digest, salt: '', length: 2 * key_len, info: info)
|
188
|
+
else
|
189
|
+
HKDF.new(input_keying_material, algorithm: 'SHA256', info: info).next_bytes(2 * key_len)
|
190
|
+
end
|
106
191
|
|
107
|
-
|
108
|
-
ec.verify(OpenSSL::Digest::SHA256.new, Base64.strict_decode64(@token[:signature]), signed_string)
|
109
|
-
end.any?
|
192
|
+
[key_bytes[0..key_len - 1], key_bytes[key_len..2 * key_len]]
|
110
193
|
end
|
111
194
|
|
112
|
-
def
|
113
|
-
|
195
|
+
def valid_mac?(mac_key)
|
196
|
+
data = Base64.strict_decode64(@signed_message[:encryptedMessage])
|
197
|
+
tag = @signed_message[:tag]
|
198
|
+
mac = OpenSSL::HMAC.digest(new_digest, mac_key, data)
|
199
|
+
|
200
|
+
compare(Base64.strict_encode64(mac), tag)
|
201
|
+
end
|
202
|
+
|
203
|
+
def decrypt(key, encrypted)
|
204
|
+
c = new_cipher
|
114
205
|
c.key = key
|
115
206
|
c.decrypt
|
116
207
|
|
117
208
|
c.update(Base64.strict_decode64(encrypted)) + c.final
|
118
209
|
end
|
119
210
|
|
120
|
-
def
|
121
|
-
|
122
|
-
|
211
|
+
def validate_message
|
212
|
+
validator = Aliquot::Validator::EncryptedMessageValidator.new(@message)
|
213
|
+
validator.validate
|
123
214
|
|
124
|
-
|
215
|
+
# Output is hashed with symbolized keys.
|
216
|
+
validator.output
|
125
217
|
end
|
126
218
|
|
127
|
-
|
219
|
+
##
|
220
|
+
# Check if the token is expired, according to the messageExpiration included
|
221
|
+
# in the token.
|
222
|
+
def expired?
|
223
|
+
@message[:messageExpiration].to_f / 1000.0 <= Time.now.to_f
|
224
|
+
end
|
225
|
+
|
226
|
+
def new_cipher
|
227
|
+
case protocol_version
|
228
|
+
when 'ECv1'
|
229
|
+
OpenSSL::Cipher::AES128.new(:CTR)
|
230
|
+
when 'ECv2'
|
231
|
+
OpenSSL::Cipher::AES256.new(:CTR)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
|
235
|
+
def new_digest
|
236
|
+
OpenSSL::Digest::SHA256.new
|
237
|
+
end
|
238
|
+
|
239
|
+
def compare(a, b)
|
128
240
|
return false unless a.length == b.length
|
129
241
|
|
130
242
|
diffs = 0
|
@@ -137,31 +249,5 @@ module Aliquot
|
|
137
249
|
|
138
250
|
diffs.zero?
|
139
251
|
end
|
140
|
-
|
141
|
-
private
|
142
|
-
|
143
|
-
# Keys are derived according to the Google Pay specification.
|
144
|
-
def derive_keys(ephemeral_public_key, shared_secret, info)
|
145
|
-
input_keying_material = Base64.strict_decode64(ephemeral_public_key) + Base64.strict_decode64(shared_secret)
|
146
|
-
|
147
|
-
if OpenSSL.const_defined?(:KDF) && OpenSSL::KDF.respond_to?(:hkdf)
|
148
|
-
h = OpenSSL::Digest::SHA256.new
|
149
|
-
key_bytes = OpenSSL::KDF.hkdf(input_keying_material, hash: h, salt: '', length: 32, info: info)
|
150
|
-
else
|
151
|
-
key_bytes = HKDF.new(input_keying_material, algorithm: 'SHA256', info: info).next_bytes(32)
|
152
|
-
end
|
153
|
-
|
154
|
-
[key_bytes[0..15], key_bytes[16..32]]
|
155
|
-
end
|
156
|
-
|
157
|
-
def check_shared_secret
|
158
|
-
begin
|
159
|
-
decoded = Base64.strict_decode64(@shared_secret)
|
160
|
-
rescue
|
161
|
-
raise InvalidSharedSecretError, 'shared_secret must be base64'
|
162
|
-
end
|
163
|
-
|
164
|
-
raise InvalidSharedSecretError, 'shared_secret must be 32 bytes when base64 decoded' unless decoded.length == 32
|
165
|
-
end
|
166
252
|
end
|
167
253
|
end
|
data/lib/aliquot/validator.rb
CHANGED
@@ -21,6 +21,7 @@ module Aliquot
|
|
21
21
|
integer_string?: 'must be string encoded integer',
|
22
22
|
month?: 'must be a month (1..12)',
|
23
23
|
year?: 'must be a year (2000..3000)',
|
24
|
+
base64_asn1?: 'must be base64 encoded asn1 value',
|
24
25
|
|
25
26
|
authMethodCryptogram3DS: 'authMethod CRYPTOGRAM_3DS requires eciIndicator',
|
26
27
|
authMethodCard: 'eciIndicator/cryptogram must be omitted when PAN_ONLY',
|
@@ -61,6 +62,8 @@ module Aliquot
|
|
61
62
|
predicate(:month?) { |x| x.between?(1, 12) }
|
62
63
|
|
63
64
|
predicate(:year?) { |x| x.between?(2000, 3000) }
|
65
|
+
|
66
|
+
predicate(:base64_asn1?) { |x| OpenSSL::ASN1.decode(Base64.strict_decode64(x)) rescue false }
|
64
67
|
end
|
65
68
|
|
66
69
|
# Base for DRY-Validation schemas used in Aliquot.
|
@@ -71,13 +74,32 @@ module Aliquot
|
|
71
74
|
end
|
72
75
|
end
|
73
76
|
|
77
|
+
# Schema used for the 'intermediateSigningKey' hash included in ECv2.
|
78
|
+
IntermediateSigningKeySchema = Dry::Validation.Schema(BaseSchema) do
|
79
|
+
required(:signedKey).filled(:str?, :json_string?)
|
80
|
+
|
81
|
+
# TODO: Check if elements of array are valid signatures
|
82
|
+
required(:signatures).filled(:array?) { each { base64? & base64_asn1? } }
|
83
|
+
end
|
84
|
+
|
85
|
+
SignedKeySchema = Dry::Validation.Schema(BaseSchema) do
|
86
|
+
required(:keyExpiration).filled(:integer_string?)
|
87
|
+
required(:keyValue).filled(:ec_public_key?)
|
88
|
+
end
|
89
|
+
|
74
90
|
# DRY-Validation schema for Google Pay token
|
75
91
|
TokenSchema = Dry::Validation.Schema(BaseSchema) do
|
76
|
-
required(:signature).filled(:str?, :base64?)
|
92
|
+
required(:signature).filled(:str?, :base64?, :base64_asn1?)
|
77
93
|
|
78
94
|
# Currently supposed to be ECv1, but may evolve.
|
79
95
|
required(:protocolVersion).filled(:str?)
|
80
96
|
required(:signedMessage).filled(:str?, :json_string?)
|
97
|
+
|
98
|
+
optional(:intermediateSigningKey).schema(IntermediateSigningKeySchema)
|
99
|
+
|
100
|
+
rule('ECv2 implies intermediateSigningKey': %i[protocolVersion intermediateSigningKey]) do |version, intermediatekey|
|
101
|
+
version.eql?('ECv2') > intermediatekey.filled?
|
102
|
+
end
|
81
103
|
end
|
82
104
|
|
83
105
|
# DRY-Validation schema for signedMessage component Google Pay token
|
@@ -180,5 +202,14 @@ module Aliquot
|
|
180
202
|
@schema = EncryptedMessageSchema
|
181
203
|
end
|
182
204
|
end
|
205
|
+
|
206
|
+
class SignedKeyValidator
|
207
|
+
include InstanceMethods
|
208
|
+
class Error < ::Aliquot::Error; end
|
209
|
+
def initialize(input)
|
210
|
+
@input = input
|
211
|
+
@schema = SignedKeySchema
|
212
|
+
end
|
213
|
+
end
|
183
214
|
end
|
184
215
|
end
|
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.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Clearhaus
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-01-
|
11
|
+
date: 2019-01-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-validation
|
@@ -58,42 +58,42 @@ dependencies:
|
|
58
58
|
requirements:
|
59
59
|
- - "~>"
|
60
60
|
- !ruby/object:Gem::Version
|
61
|
-
version: 0.
|
61
|
+
version: '0.8'
|
62
62
|
type: :development
|
63
63
|
prerelease: false
|
64
64
|
version_requirements: !ruby/object:Gem::Requirement
|
65
65
|
requirements:
|
66
66
|
- - "~>"
|
67
67
|
- !ruby/object:Gem::Version
|
68
|
-
version: 0.
|
68
|
+
version: '0.8'
|
69
69
|
- !ruby/object:Gem::Dependency
|
70
|
-
name:
|
70
|
+
name: rspec
|
71
71
|
requirement: !ruby/object:Gem::Requirement
|
72
72
|
requirements:
|
73
73
|
- - "~>"
|
74
74
|
- !ruby/object:Gem::Version
|
75
|
-
version: '
|
75
|
+
version: '3'
|
76
76
|
type: :development
|
77
77
|
prerelease: false
|
78
78
|
version_requirements: !ruby/object:Gem::Requirement
|
79
79
|
requirements:
|
80
80
|
- - "~>"
|
81
81
|
- !ruby/object:Gem::Version
|
82
|
-
version: '
|
82
|
+
version: '3'
|
83
83
|
- !ruby/object:Gem::Dependency
|
84
|
-
name:
|
84
|
+
name: pry
|
85
85
|
requirement: !ruby/object:Gem::Requirement
|
86
86
|
requirements:
|
87
87
|
- - "~>"
|
88
88
|
- !ruby/object:Gem::Version
|
89
|
-
version: '
|
89
|
+
version: '0'
|
90
90
|
type: :development
|
91
91
|
prerelease: false
|
92
92
|
version_requirements: !ruby/object:Gem::Requirement
|
93
93
|
requirements:
|
94
94
|
- - "~>"
|
95
95
|
- !ruby/object:Gem::Version
|
96
|
-
version: '
|
96
|
+
version: '0'
|
97
97
|
description:
|
98
98
|
email: hello@clearhaus.com
|
99
99
|
executables: []
|