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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ba838f9284bf05a6b396005bf21c4bbecefe36432457b4b5dfb48c76997353e
4
- data.tar.gz: 96a202458e4bb76634375857f4af9c3a1f0f3529d0e2089e64b295345590778e
3
+ metadata.gz: a327bbbe5f475b924454c120979cb3bc965c5363c2740366a9f1abea19a59707
4
+ data.tar.gz: 044dd4f0c2daa2d72685133fbd27408247281449276c0e36cba1141778d98176
5
5
  SHA512:
6
- metadata.gz: 6607632c5489062965cc3a95eb66b2a572af1821f0b4506d2e5e20ea23ba974741beb6091dfc16de78b3ba53ccab62a87c16a183dbf1d4f8b6ae9b01d6bde94c
7
- data.tar.gz: d39edb966edf213869963edfaf753893a11dbe13b8f72af130be3ffb0c12da3a1dbf65b21448492500be9d63abfb8fffbfb7fdd5087dd9fbce292cd1dbe66bd0
6
+ metadata.gz: f25a55664501aa93322675512975d3471c4586112855d2d30f32b0f61fcad6240fdd3750d36e418feaa415f3a49ee752271f2d7c1dc5fe28cd6615fc5d16811b
7
+ data.tar.gz: e48a4d748adc053caa2e8a7770d5ef580fee0867497fa2d98dfac8cfc202a8183e3e8c900b56bac1cd121a95fadc4d8699511442263e93076d10e95ce53407af
@@ -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, 'only ECv1 protocolVersion is supported'
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
- raise InvalidSignatureError unless valid_signature?
53
+ check_signature
48
54
 
49
- validator = Aliquot::Validator::SignedMessage.new(JSON.parse(@token[:signedMessage]))
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 self.class.valid_mac?(mac_key, signed_message[:encryptedMessage], signed_message[:tag])
60
- raise InvalidMacError
61
- end
63
+ raise InvalidMacError unless valid_mac?(mac_key)
62
64
 
63
65
  begin
64
- @message = JSON.parse(self.class.decrypt(aes_key, signed_message[:encryptedMessage]))
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
- message_validator = Aliquot::Validator::EncryptedMessageValidator.new(@message)
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 == 'ECv1'
85
+ SUPPORTED_PROTOCOL_VERSIONS.include?(protocol_version)
88
86
  end
89
87
 
90
- ##
91
- # Check if the token is expired, according to the messageExpiration included
92
- # in the token.
93
- def expired?
94
- @message[:messageExpiration].to_f / 1000.0 <= Time.now.to_f
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 valid_signature?
98
- signed_string = ['Google', @merchant_id, protocol_version, @token[:signedMessage]].map do |str|
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
- keys = JSON.parse(@signing_keys)['keys']
103
- # Check if signature was performed with any possible key.
104
- keys.map do |key|
105
- next if key['protocolVersion'] != protocol_version
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
- ec = OpenSSL::PKey::EC.new(Base64.strict_decode64(key['keyValue']))
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 self.decrypt(key, encrypted)
113
- c = OpenSSL::Cipher::AES128.new(:CTR)
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 self.valid_mac?(mac_key, data, tag)
121
- digest = OpenSSL::Digest::SHA256.new
122
- mac = OpenSSL::HMAC.digest(digest, mac_key, Base64.strict_decode64(data))
211
+ def validate_message
212
+ validator = Aliquot::Validator::EncryptedMessageValidator.new(@message)
213
+ validator.validate
123
214
 
124
- compare(Base64.strict_encode64(mac), tag)
215
+ # Output is hashed with symbolized keys.
216
+ validator.output
125
217
  end
126
218
 
127
- def self.compare(a, b)
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
@@ -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.11.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-08 00:00:00.000000000 Z
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.6.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.6.0
68
+ version: '0.8'
69
69
  - !ruby/object:Gem::Dependency
70
- name: pry
70
+ name: rspec
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - "~>"
74
74
  - !ruby/object:Gem::Version
75
- version: '0'
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: '0'
82
+ version: '3'
83
83
  - !ruby/object:Gem::Dependency
84
- name: rspec
84
+ name: pry
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - "~>"
88
88
  - !ruby/object:Gem::Version
89
- version: '3'
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: '3'
96
+ version: '0'
97
97
  description:
98
98
  email: hello@clearhaus.com
99
99
  executables: []