pedicel 0.0.2 → 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: d2328aa143a9d49fbec83ddd1550c648267e039a
4
- data.tar.gz: a90ab943850850e230bb6baa74dc498fab689ea2
2
+ SHA256:
3
+ metadata.gz: 628cf5b61cec879b91695fdbbf8f9c3a0803a9c7be2645076d34da772f030b86
4
+ data.tar.gz: 04dd8ff38fe186fdf5f79a2c648a9482e63add4df9ac2b42aa2b229b2c4ae3fa
5
5
  SHA512:
6
- metadata.gz: dc1dded2040b5efb55a74617873cb0ac5884d3fbdb7cb5694aa6bb1c6b5869aa01d66d0c38224c4759302cdc85f4aa1826859d1939c039925344abe604ec2dff
7
- data.tar.gz: a719c00c24208ceffd9666df3f1925b0a9a16ad1c753ef688366b9703812b343d36e1705985f613148e6927e5422b48fcc8903ee107841686dc7e7aa2e7a788f
6
+ metadata.gz: 111ea8c7a4baf9cdf7128aeb710cd9ea8d51a31e722d2ae93939812bc12cee9c388476e62b33042ca5fe41324239b106f87a128e2ea599b57b9d2771ec7521fe
7
+ data.tar.gz: ad718264658b4c26ae9c62644e98a5b6056637b781a3100c01666e08b1d07ff569c21abe1cb7bd18647bcd904898730b4b081349552b37d81672f72242b4a9b5
data/lib/pedicel/base.rb CHANGED
@@ -1,20 +1,18 @@
1
1
  require 'openssl'
2
2
  require 'base64'
3
+ require 'pedicel/validator'
3
4
 
4
5
  module Pedicel
5
6
  class Base
6
- SUPPORTED_VERSIONS = [:EC_v1]
7
+ SUPPORTED_VERSIONS = [:EC_v1].freeze
7
8
 
8
- def initialize(token, now: Time.now)
9
- @token = token
10
- end
11
-
12
- def validate_content(now: Time.now)
13
- raise VersionError, "unsupported version: #{version}" unless SUPPORTED_VERSIONS.include?(@token['version'])
9
+ attr_reader :config
14
10
 
15
- raise ReplayAttackError, "token signature time indicates a replay attack (age #{now-cms_signing_time})" unless signing_time_ok?(now: now)
11
+ def initialize(token, config: Pedicel::DEFAULT_CONFIG)
12
+ Validator.validate_token(token)
16
13
 
17
- raise SignatureError unless valid_signature?
14
+ @token = token
15
+ @config = config
18
16
  end
19
17
 
20
18
  def version
@@ -38,68 +36,79 @@ module Pedicel
38
36
  end
39
37
 
40
38
  def application_data
41
- return nil unless @token['applicationData']
42
-
43
- [@token['applicationData']].pack('H*')
44
- end
45
-
46
- def signing_time_ok?(now: Time.now)
47
- # "Inspect the CMS signing time of the signature, as defined by section 11.3
48
- # of RFC 5652. If the time signature and the transaction time differ by more
49
- # than a few minutes, it's possible that the token is a replay attack."
50
- # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
39
+ return nil unless @token['header']['applicationData']
51
40
 
52
- delta = Pedicel.config[:replay_threshold_seconds]
53
-
54
- cms_signing_time.between?(now - delta, now + delta)
55
- # Deliberately ignoring leap seconds.
41
+ [@token['header']['applicationData']].pack('H*')
56
42
  end
57
43
 
58
44
  def private_key_class
59
- {EC_v1: OpenSSL::PKey::EC, RSA_v1: OpenSSL::PKey::RSA}[version]
45
+ raise VersionError, "unsupported version: #{version}" unless SUPPORTED_VERSIONS.include?(version)
46
+
47
+ { EC_v1: OpenSSL::PKey::EC, RSA_v1: OpenSSL::PKey::RSA }[version]
60
48
  end
61
49
 
62
50
  def symmetric_algorithm
63
- {EC_v1: 'aes-256-gcm', RSA_v1: 'aes-128-gcm'}[version]
51
+ raise VersionError, "unsupported version: #{version}" unless SUPPORTED_VERSIONS.include?(version)
52
+
53
+ { EC_v1: 'aes-256-gcm', RSA_v1: 'aes-128-gcm' }[version]
64
54
  end
65
55
 
66
56
  def decrypt_aes(key:)
67
57
  raise TokenFormatError, 'no encrypted data present' unless encrypted_data
68
58
 
69
59
  if OpenSSL::Cipher.new('aes-256-gcm').respond_to?(:iv_len=)
70
- # Either because you use Ruby >=2.4's native openssl lib, or if you have a
71
- # "recent enough" version of the openssl gem available.
60
+ # Either because you use Ruby >=2.4's native openssl lib, or if you have
61
+ # a "recent enough" version of the openssl gem available.
62
+ decrypt_aes_openssl(key)
63
+ else
64
+ decrypt_aes_gem(key)
65
+ end
66
+ end
72
67
 
73
- cipher = OpenSSL::Cipher.new(symmetric_algorithm)
74
- cipher.decrypt
68
+ private
69
+ def decrypt_aes_openssl(key)
70
+ cipher = OpenSSL::Cipher.new(symmetric_algorithm)
71
+ cipher.decrypt
75
72
 
73
+ begin
76
74
  cipher.key = key
77
- cipher.iv_len = 16 # Must be set before the IV because default is 12 and
78
- # only IVs of length `iv_len` will be accepted.
79
- cipher.iv = "\x00".b * cipher.iv_len
75
+ rescue ArgumentError => e
76
+ raise Pedicel::AesKeyError, "invalid key: #{e.message}"
77
+ end
80
78
 
81
- split_position = encrypted_data.length - cipher.iv_len
82
- tag = encrypted_data.slice(split_position, cipher.iv_len)
83
- untagged_encrypted_data = encrypted_data.slice(0, split_position)
79
+ # iv_len must be set before the IV because default is 12 and
80
+ # only IVs of length `iv_len` will be accepted.
81
+ cipher.iv_len = 16
82
+ cipher.iv = 0.chr * cipher.iv_len
84
83
 
85
- cipher.auth_tag = tag
86
- cipher.auth_data = ''.b
84
+ split_position = encrypted_data.length - cipher.iv_len
85
+ tag = encrypted_data.slice(split_position, cipher.iv_len)
86
+ untagged_encrypted_data = encrypted_data.slice(0, split_position)
87
87
 
88
- cipher.update(untagged_encrypted_data) << cipher.final
89
- else
90
- require 'aes256gcm_decrypt'
88
+ cipher.auth_tag = tag
89
+ cipher.auth_data = ''.b
91
90
 
92
- Aes256GcmDecrypt::decrypt(encrypted_data, key)
93
- end
91
+ cipher.update(untagged_encrypted_data) << cipher.final
92
+ rescue OpenSSL::Cipher::CipherError
93
+ raise Pedicel::AesKeyError, 'wrong key'
94
+ end
95
+
96
+ def decrypt_aes_gem(key)
97
+ require 'aes256gcm_decrypt'
98
+
99
+ Aes256GcmDecrypt.decrypt(encrypted_data, key)
100
+ rescue Aes256GcmDecrypt::Error => e
101
+ raise Pedicel::AesKeyError, "decryption failed: #{e}"
94
102
  end
103
+ public
95
104
 
96
105
  def valid_signature?(now: Time.now)
97
- true if validate_signature(now: now)
106
+ !!verify_signature(now: now)
98
107
  rescue
99
108
  false
100
109
  end
101
110
 
102
- def verify_signature(ca_certificate_pem: Pedicel.config[:apple_root_ca_g3_cert_pem], now: Time.now)
111
+ def verify_signature(ca_certificate_pem: @config[:trusted_ca_pem], now: Time.now)
103
112
  raise SignatureError, 'no signature present' unless signature
104
113
 
105
114
  begin
@@ -108,24 +117,34 @@ module Pedicel
108
117
  raise SignatureError, "invalid PKCS #7 signature: #{e.message}"
109
118
  end
110
119
 
111
- # 1.a
112
- # Ensure that the certificates contain the correct custom OIDs: (...).
113
- # The value for these marker OIDs doesn't matter, only their presence.
114
- leaf, intermediate = self.class.verify_signature_certificate_oids(signature: s)
115
-
116
120
  begin
117
- root = OpenSSL::X509::Certificate.new(ca_certificate_pem)
121
+ trusted_root = OpenSSL::X509::Certificate.new(ca_certificate_pem)
118
122
  rescue => e
119
- raise CertificateError, "invalid root certificate: #{e.message}"
123
+ raise CertificateError, "invalid trusted root certificate: #{e.message}"
120
124
  end
121
125
 
126
+ # 1.a
127
+ # Ensure that the certificates contain the correct custom OIDs: (...).
128
+ # The value for these marker OIDs doesn't matter, only their presence.
129
+ leaf, intermediate, other = self.class.extract_certificates(signature: s)
130
+ # Implicit since these are the ones extracted.
131
+
122
132
  # 1.b
123
133
  # Ensure that the root CA is the Apple Root CA - G3. (...)
124
- self.class.verify_root_certificate(root: root, intermediate: intermediate)
134
+ if other
135
+ self.class.verify_root_certificate(trusted_root: trusted_root, root: other)
136
+ # Allow no other certificate than the root.
137
+ #else
138
+ # no other certificate is not extracted from the signature, and thus, we
139
+ # trust the trusted root.
140
+ end
125
141
 
126
142
  # 1.c
127
- # Ensure that there is a valid X.509 chain of trust from the signature to the root CA.
128
- self.class.verify_x509_chain(root: root, intermediate: intermediate, leaf: leaf)
143
+ # Ensure that there is a valid X.509 chain of trust from the signature to
144
+ # the root CA.
145
+ self.class.verify_x509_chain(root: trusted_root, intermediate: intermediate, leaf: leaf)
146
+ # We "only" check the *certificate* chain (from leaf to root). Below (in
147
+ # 1.d) is checked that the signature is created with the leaf.
129
148
 
130
149
  # 1.d
131
150
  # Validate the token's signature.
@@ -140,58 +159,91 @@ module Pedicel
140
159
  self
141
160
  end
142
161
 
143
- private
162
+ def self.extract_certificates(signature:, config: Pedicel::DEFAULT_CONFIG)
163
+ leafs, intermediates, others = [], [], []
144
164
 
145
- def self.verify_signature_certificate_oids(signature:)
146
- leaf = signature.certificates.find{|c| c.extensions.find{|e| e.oid == Pedicel.config[:oids][:leaf_certificate]}}
147
- unless leaf
148
- raise SignatureError, "no leaf certificate found (OID #{Pedicel.config[:oids][:leaf_certificate]})"
149
- end
165
+ signature.certificates.each do |certificate|
166
+ leaf_or_intermediate = false
167
+
168
+ certificate.extensions.each do |extension|
169
+ case extension.oid
170
+ when config[:oid_intermediate_certificate]
171
+ intermediates << certificate
172
+ leaf_or_intermediate = true
173
+ when config[:oid_leaf_certificate]
174
+ leafs << certificate
175
+ leaf_or_intermediate = true
176
+ end
177
+ end
150
178
 
151
- intermediate = signature.certificates.find{|c| c.extensions.find{|e| e.oid == Pedicel.config[:oids][:intermediate_certificate]}}
152
- unless intermediate
153
- raise SignatureError, "no intermediate certificate found (OID #{Pedicel.config[:oids][:leaf_certificate]})"
179
+ others << certificate unless leaf_or_intermediate
154
180
  end
155
181
 
156
- [leaf, intermediate]
182
+ raise SignatureError, "no unique leaf certificate found (OID #{config[:oid_leaf_certificate]})" unless leafs.length == 1
183
+ raise SignatureError, "no unique intermediate certificate found (OID #{config[:oid_intermediate_certificate]})" unless intermediates.length == 1
184
+ raise SignatureError, "too many certificates found in the signature: #{others.map(&:subject).join('; ')}" if others.length > 1
185
+
186
+ [leafs.first, intermediates.first, others.first]
157
187
  end
158
188
 
159
- def self.verify_root_certificate(root:, intermediate:)
160
- unless intermediate.issuer == root.subject
161
- raise SignatureError, 'root certificate has not issued intermediate certificate'
162
- end
189
+ def self.verify_root_certificate(root:, trusted_root:)
190
+ raise SignatureError, 'root certificate is not trusted' unless root.to_der == trusted_root.to_der
191
+
192
+ true
163
193
  end
164
194
 
165
195
  def self.verify_x509_chain(root:, intermediate:, leaf:)
166
- valid_chain = OpenSSL::X509::Store.new.
167
- add_cert(root).
168
- add_cert(intermediate).
169
- verify(leaf)
196
+ store = OpenSSL::X509::Store.new.add_cert(root)
197
+
198
+ unless store.verify(root)
199
+ raise SignatureError, "invalid chain due to root: #{store.error_string}"
200
+ end
201
+
202
+ unless store.verify(intermediate)
203
+ raise SignatureError, "invalid chain due to intermediate: #{store.error_string}"
204
+ end
205
+
206
+ begin
207
+ store.add_cert(intermediate)
208
+ rescue OpenSSL::X509::StoreError
209
+ raise SignatureError, "invalid chain due to intermediate"
210
+ end
170
211
 
171
- raise SignatureError, 'invalid chain of trust' unless valid_chain
212
+ begin
213
+ store.add_cert(leaf)
214
+ rescue OpenSSL::X509::StoreError
215
+ raise SignatureError, "invalid chain due to leaf"
216
+ end
217
+
218
+ unless store.verify(leaf)
219
+ raise SignatureError, "invalid chain due to leaf: #{store.error_string}"
220
+ end
221
+
222
+ true
172
223
  end
173
224
 
174
- def self.verify_signed_time(signature:, now:)
225
+ def self.verify_signed_time(signature:, now:, config: Pedicel::DEFAULT_CONFIG)
175
226
  # Inspect the CMS signing time of the signature, as defined by section
176
227
  # 11.3 of RFC 5652. If the time signature and the transaction time differ
177
228
  # by more than a few minutes, it's possible that the token is a replay
178
229
  # attack.
230
+ # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
179
231
 
180
232
  unless signature.signers.length == 1
181
233
  raise SignatureError, 'not 1 signer, unable to determine signing time'
182
234
  end
183
235
  signed_time = signature.signers.first.signed_time
184
236
 
185
- few_min = Pedicel.config[:replay_threshold_seconds]
237
+ few_min = config[:replay_threshold_seconds]
186
238
 
187
- # Time objects. DST aware. Ignoring leap seconds.
188
- return if signed_time.between?(now - few_min, now + few_min) # Both ends included.
239
+ # Time objects. DST aware. Ignoring leap seconds. Both ends included.
240
+ return true if signed_time.between?(now - few_min, now + few_min)
189
241
 
190
242
  diff = signed_time - now
191
243
  if diff.negative?
192
- raise SignatureError, "signature too old; signed #{-diff.to_i}s ago"
244
+ raise SignatureError, "signature too old; signed #{-diff}s ago"
193
245
  end
194
- raise SignatureError, "signature too new; signed #{diff.to_i}s in the future"
246
+ raise SignatureError, "signature too new; signed #{diff}s in the future"
195
247
  end
196
248
  end
197
249
  end
data/lib/pedicel/ec.rb CHANGED
@@ -7,47 +7,44 @@ module Pedicel
7
7
  end
8
8
 
9
9
  def decrypt(symmetric_key: nil, merchant_id: nil, certificate: nil, private_key: nil,
10
- ca_certificate_pem: Pedicel.config[:apple_root_ca_g3_cert_pem], now: Time.now)
11
- raise ArgumentError 'invalid argument combination' unless \
12
- !symmetric_key.nil? ^ ((!merchant_id.nil? ^ !certificate.nil?) && !private_key.nil?)
13
- # .-------------------'--------. .----------'----. .-------------''---.
14
- # | symmetric_key can be | | merchant_id | | Both private_key |
15
- # | derived from private_key | | (byte string) | | and merchant_id |
16
- # | and the shared_secret--- | | can be | | is necessary to |
17
- # | which can be derived from | | derived from | | derive the |
18
- # | the private_key and the | | the public | | symmetric key |
19
- # | token's ephemeralPublicKey | | certificate | '------------------'
20
- # '----------------------------' '---------------'
21
-
22
- if private_key
23
- symmetric_key = symmetric_key(private_key: private_key,
24
- certificate: certificate,
25
- merchant_id: merchant_id)
10
+ ca_certificate_pem: @config[:trusted_ca_pem], now: Time.now)
11
+ # Check for necessary parameters.
12
+ unless symmetric_key || ((merchant_id || certificate) && private_key)
13
+ raise ArgumentError, 'missing parameters'
14
+ end
15
+
16
+ # Check for uniqueness among the supplied parameters used directly here.
17
+ if symmetric_key && (merchant_id || certificate || private_key)
18
+ raise ArgumentError, "leave out other parameters when supplying 'symmetric_key'"
26
19
  end
27
20
 
28
21
  verify_signature(ca_certificate_pem: ca_certificate_pem, now: now)
22
+
23
+ symmetric_key ||= symmetric_key(private_key: private_key,
24
+ merchant_id: merchant_id,
25
+ certificate: certificate)
26
+
29
27
  decrypt_aes(key: symmetric_key)
30
28
  end
31
29
 
32
- def symmetric_key(shared_secret: nil, private_key: nil, merchant_id: nil, certificate: nil)
33
- raise ArgumentError 'invalid argument combination' unless \
34
- (!shared_secret.nil? ^ !private_key.nil?) && (!merchant_id.nil? ^ !certificate.nil?)
35
- # .--------------------'. .----------------'| .-----------------'--.
36
- # | shared_secret can | | shared_secret | | merchant_id (byte |
37
- # | be derived from the | | and merchant_id | | string can be |
38
- # | private_key and the | | is necessary to | | derived from the |
39
- # | ephemeralPublicKey | | derive the | | public certificate |
40
- # '---------------------' | symmetric_key | '--------------------'
41
- # '-----------------'
30
+ def symmetric_key(private_key: nil, merchant_id: nil, certificate: nil)
31
+ # Check for necessary parameters.
32
+ unless private_key && (merchant_id || certificate)
33
+ raise ArgumentError, 'missing parameters'
34
+ end
35
+
36
+ # Check for uniqueness among the supplied parameters.
37
+ if merchant_id && certificate
38
+ raise ArgumentError, "leave out 'certificate' when supplying 'merchant_id'"
39
+ end
40
+
41
+ shared_secret = shared_secret(private_key: private_key)
42
42
 
43
- shared_secret = shared_secret(private_key: private_key) if private_key
44
- merchant_id = self.class.merchant_id(certificate: certificate) if certificate
43
+ merchant_id ||= self.class.merchant_id(certificate: certificate)
45
44
 
46
45
  self.class.symmetric_key(shared_secret: shared_secret, merchant_id: merchant_id)
47
46
  end
48
47
 
49
- # Extract the shared secret from one public key (the ephemeral) and one
50
- # private key.
51
48
  def shared_secret(private_key:)
52
49
  begin
53
50
  privkey = OpenSSL::PKey::EC.new(private_key)
@@ -58,19 +55,21 @@ module Pedicel
58
55
  begin
59
56
  pubkey = OpenSSL::PKey::EC.new(ephemeral_public_key).public_key
60
57
  rescue => e
61
- raise EcKeyError, "invalid format of ephemeralPublicKey (from token) for EC: #{e.message}"
58
+ raise EcKeyError, "invalid ephemeralPublicKey (from token) for EC: #{e.message}"
62
59
  end
63
60
 
64
61
  unless privkey.group == pubkey.group
65
- raise EcKeyError,
66
- "private_key curve '#{privkey.group.curve_name}' differ from " \
67
- "ephemeralPublicKey (from token) curve '#{pubkey.group.curve_name}'"
62
+ raise EcKeyError, "private_key curve '%s' differs from token ephemeralPublicKey curve '%s'" %
63
+ [privkey.group.curve_name, pubkey.group.curve_name]
68
64
  end
69
65
 
70
66
  privkey.dh_compute_key(pubkey)
71
67
  end
72
68
 
73
69
  def self.symmetric_key(merchant_id:, shared_secret:)
70
+ raise ArgumentError, 'merchant_id must be a SHA256' unless merchant_id.is_a?(String) && merchant_id.length == 32
71
+ raise ArgumentError, 'shared_secret must be a string' unless shared_secret.is_a?(String)
72
+
74
73
  # http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Ar2.pdf
75
74
  # Section 5.8.1.1, The Single-Step KDF Specification.
76
75
  #
@@ -92,18 +91,18 @@ module Pedicel
92
91
  # > hashlen` leftmost bits of `K(reps)`.
93
92
  # > 7. Return `K(1) || K(2) || ... || K(reps-1) || K_Last`.
94
93
  #
95
- # Digest::SHA256 will do the calculations when we throw Z and OtherInfo into
96
- # the digest.
94
+ # Digest::SHA256 will do the calculations when we throw Z and OtherInfo
95
+ # into the digest.
97
96
 
98
97
  sha256 = Digest::SHA256.new
99
98
 
100
- # Step 3:
99
+ # Step 3
101
100
  sha256 << "\x00\x00\x00\x01"
102
101
 
103
- # Z:
102
+ # Z
104
103
  sha256 << shared_secret
105
104
 
106
- # OtherInfo:
105
+ # OtherInfo
107
106
  # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
108
107
  sha256 << "\x0d" + 'id-aes256-GCM' # AlgorithmID
109
108
  sha256 << 'Apple' # PartyUInfo
@@ -112,7 +111,7 @@ module Pedicel
112
111
  sha256.digest
113
112
  end
114
113
 
115
- def self.merchant_id(certificate:)
114
+ def self.merchant_id(certificate:, config: Pedicel::DEFAULT_CONFIG)
116
115
  begin
117
116
  cert = OpenSSL::X509::Certificate.new(certificate)
118
117
  rescue => e
@@ -120,11 +119,11 @@ module Pedicel
120
119
  end
121
120
 
122
121
  merchant_id_hex =
123
- cert.
124
- extensions.
125
- find { |x| x.oid == Pedicel.config[:oids][:merchant_identifier_field] }&.
126
- value&. # Hex encoded Merchant ID plus perhaps extra non-hex chars.
127
- delete("^[0-9a-fA-F]") # Remove non-hex chars.
122
+ cert
123
+ .extensions
124
+ .find { |x| x.oid == config[:oid_merchant_identifier_field] }
125
+ &.value # Hex encoded Merchant ID plus perhaps extra non-hex chars.
126
+ &.delete('^[0-9a-fA-F]') # Remove non-hex chars.
128
127
 
129
128
  raise CertificateError, 'no merchant identifier in certificate' unless merchant_id_hex
130
129
 
@@ -163,6 +162,8 @@ module Pedicel
163
162
  unless signature.verify(certificates, store, message, flags)
164
163
  raise SignatureError, 'signature does not match the message'
165
164
  end
165
+
166
+ true
166
167
  end
167
168
  end
168
169
  end
@@ -1,138 +1,168 @@
1
1
  require 'dry-validation'
2
2
  require 'base64'
3
+ require 'openssl'
3
4
 
4
5
  module Pedicel
6
+ # Validation class for Apple Pay Payment Token and associated data:
7
+ # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
8
+ # This purposefully only does syntactic validation (as opposed to semantic).
5
9
  class Validator
6
10
  class Error < StandardError; end
7
11
  class TokenFormatError < Error; end
8
12
  class TokenDataFormatError < Error; end
9
13
 
10
- DRY_CUSTOM_PREDICATE_ERRORS = {
11
- base64?: 'invalid base64',
12
- hex?: 'invalid hex',
13
- pan?: 'invalid pan',
14
- yymmdd?: 'invalid date format YYMMDD',
15
- }
16
-
17
14
  module Predicates
18
15
  include Dry::Logic::Predicates
19
16
 
20
- predicate(:base64?) { |value| !Base64.decode64(value).nil? rescue false }
17
+ CUSTOM_PREDICATE_ERRORS = {
18
+ base64?: 'invalid base64',
19
+ hex?: 'invalid hex',
20
+ pan?: 'invalid pan',
21
+ yymmdd?: 'invalid date format YYMMDD',
22
+ ec_public_key?: 'is not an EC public key',
23
+ pkcs7_signature?: 'is not a PKCS7 Signature',
24
+ eci?: 'not an ECI indicator',
25
+ hex_sha256?: 'not hex-encoded SHA256',
26
+ base64_sha256?: 'not base64-encoded SHA256',
27
+ }.freeze
28
+
29
+ # Support Ruby 2.3, but use the faster #match? when available.
30
+ match_b = String.new.respond_to?(:match?) ? lambda{|s, re| s.match?(re)} : lambda{|s, re| !!(s =~ re)}
31
+
32
+ predicate(:base64?) do |x|
33
+ str?(x) &&
34
+ match_b.(x, /\A[=A-Za-z0-9+\/]*\z/) && # allowable chars
35
+ x.length.remainder(4).zero? && # multiple of 4
36
+ !match_b.(x, /=[^$=]/) && # may only end with ='s
37
+ !match_b.(x, /===/) # at most 2 ='s
38
+ end
39
+
40
+ # We should figure out how strict we should be. Hopefully we can discard
41
+ # the above base64? predicate and use the following simpler one:
42
+ #predicate(:strict_base64?) { |x| !!Base64.strict_decode64(x) rescue false }
21
43
 
22
- predicate(:hex?) { |value| !Regexp.new(/\A[a-f0-9-]*\z/i).match(value).nil? }
44
+ predicate(:base64_sha256?) { |x| base64?(x) && Base64.decode64(x).length == 32 }
23
45
 
24
- predicate(:pan?) { |value| !Regexp.new(/\A[0-9]{13,19}\z/).match(value).nil? }
46
+ predicate(:hex?) { |x| str?(x) && match_b.(x, /\A[a-f0-9]*\z/i) }
25
47
 
26
- predicate(:yymmdd?) do |value|
27
- return false unless value.length == 6
28
- Time.new(value[0..1],value[2..3], value[4..5]).is_a?(Time) rescue false
29
- end
48
+ predicate(:hex_sha256?) { |x| hex?(x) && x.length == 64 }
49
+
50
+ predicate(:pan?) { |x| str?(x) && match_b.(x, /\A[1-9][0-9]{11,18}\z/) }
51
+
52
+ predicate(:yymmdd?) { |x| str?(x) && match_b.(x, /\A\d{6}\z/) }
53
+
54
+ predicate(:eci?) { |x| str?(x) && match_b.(x, /\A\d{2}\z/) }
55
+
56
+ predicate(:ec_public_key?) { |x| base64?(x) && OpenSSL::PKey::EC.new(Base64.decode64(x)).check_key rescue false }
57
+
58
+ predicate(:pkcs7_signature?) { |x| base64?(x) && !!OpenSSL::PKCS7.new(Base64.decode64(x)) rescue false }
30
59
  end
31
60
 
32
61
  TokenSchema = Dry::Validation.Schema do
33
62
  configure do
63
+ # NOTE: This option removes/sanitizes hash element not mentioned/tested.
64
+ # Hurray for good documentation.
65
+ config.input_processor = :json
66
+
67
+ # In theory, I would guess that :strict below would cause a failure if
68
+ # untested keys were encountered, however this appears to not be the
69
+ # case. Anyways, it's (of course) not documented.
70
+ # config.hash_type = :strict
71
+
34
72
  predicates(Predicates)
35
73
  def self.messages
36
- super.merge(en: { errors: DRY_CUSTOM_PREDICATE_ERRORS })
74
+ super.merge(en: { errors: Predicates::CUSTOM_PREDICATE_ERRORS })
37
75
  end
38
76
  end
39
77
 
40
78
  required(:data).filled(:str?, :base64?)
41
79
 
42
80
  required(:header).schema do
43
- optional(:applicationData).filled(:str?, :hex?)
81
+ optional(:applicationData).filled(:str?, :hex?, :hex_sha256?)
82
+
83
+ optional(:ephemeralPublicKey).filled(:str?, :base64?, :ec_public_key?)
44
84
 
45
- optional(:ephemeralPublicKey).filled(:str?, :base64?)
46
85
  optional(:wrappedKey).filled(:str?, :base64?)
86
+
47
87
  rule('ephemeralPublicKey xor wrappedKey': [:ephemeralPublicKey, :wrappedKey]) do |e, w|
48
88
  e.filled? ^ w.filled?
49
89
  end
50
90
 
51
- required(:publicKeyHash).filled(:str?, :base64?)
91
+ required(:publicKeyHash).filled(:str?, :base64?, :base64_sha256?)
52
92
 
53
93
  required(:transactionId).filled(:str?, :hex?)
54
94
  end
55
95
 
56
- required(:signature).filled(:str?, :base64?)
96
+ required(:signature).filled(:str?, :base64?, :pkcs7_signature?)
57
97
 
58
- required(:version).filled(:str?, included_in?: ['EC_v1', 'RSA_v1'])
98
+ required(:version).filled(:str?, included_in?: %w[EC_v1 RSA_v1])
59
99
  end
60
100
 
61
- # Pedicel::Validator::TokenSchema.call({data: 'asdf', header: {ephemeralPublicKey: 'e', publicKeyHash: 'p', transactionId: 'f'}, signature: 's', version: 'EC_v1'})
62
-
63
101
  TokenDataSchema = Dry::Validation.Schema do
64
102
  configure do
65
103
  predicates(Predicates)
66
104
  def self.messages
67
- super.merge(en: { errors: DRY_CUSTOM_PREDICATE_ERRORS })
105
+ super.merge(en: { errors: Predicates::CUSTOM_PREDICATE_ERRORS })
68
106
  end
69
107
  end
70
108
 
71
- required(:applicationPrimaryAccountNumber).filled(:str?, :pan?)
109
+ required('applicationPrimaryAccountNumber').filled(:str?, :pan?)
72
110
 
73
- required(:applicationExpirationDate).filled(:str?, :yymmdd?)
111
+ required('applicationExpirationDate').filled(:str?, :yymmdd?)
74
112
 
75
- required(:currencyCode).filled(:str?, format?: /\A[0-9]{3}\z/)
113
+ required('currencyCode').filled(:str?, format?: /\A[0-9]{3}\z/)
76
114
 
77
- required(:transactionAmount).filled(:int?)
115
+ required('transactionAmount').filled(:int?)
78
116
 
79
- optional(:cardholderName).filled(:str?)
117
+ optional('cardholderName').filled(:str?)
80
118
 
81
- required(:deviceManufacturerIdentifier).filled(:str?, :hex?)
119
+ required('deviceManufacturerIdentifier').filled(:str?, :hex?)
82
120
 
83
- required(:paymentDataType).filled(:str?, included_in?: ['3DSecure', 'EMV'])
121
+ required('paymentDataType').filled(:str?, included_in?: %w[3DSecure EMV])
84
122
 
85
- required(:paymentData).schema do
86
- optional(:onlinePaymentCryptogram).filled(:str?, :base64?)
87
- optional(:eciIndicator).filled(:str?)
123
+ required('paymentData').schema do
124
+ optional('onlinePaymentCryptogram').filled(:str?, :base64?)
125
+ optional('eciIndicator').filled(:str?, :eci?)
88
126
 
89
- optional(:emvData).filled(:str?, :base64?)
90
- optional(:encryptedPINData).filled(:str?, :hex?)
127
+ optional('emvData').filled(:str?, :base64?)
128
+ optional('encryptedPINData').filled(:str?, :hex?)
91
129
  end
92
130
 
93
- rule('consistent paymentDataType and paymentData': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |t, cryptogram|
131
+ rule('paymentDataType affects paymentData': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |t, cryptogram|
94
132
  t.eql?('3DSecure') > cryptogram.filled?
95
133
  end
96
134
  end
97
135
 
98
- # Pedicel::Validator::TokenDataSchema.call(
99
- # applicationPrimaryAccountNumber: '1234567890123',
100
- # applicationExpirationDate: '101112',
101
- # currencyCode: '123',
102
- # transactionAmount: 12.34,
103
- # cardholderName: 'asdf',
104
- # deviceManufacturerIdentifier: 'adsf',
105
- # paymentDataType: 'asdf',
106
- # )
107
-
108
- def self.validate_token(token, now: Time.now)
136
+ def self.validate_token(token)
109
137
  validation = TokenSchema.call(token)
110
138
 
111
- raise TokenFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
139
+ raise TokenFormatError, format_errors(validation) if validation.failure?
112
140
 
113
141
  true
114
142
  end
115
143
 
116
- def self.valid_token?(token, now: Time.now)
117
- validate_token(token, now: now) rescue false
144
+ def self.valid_token?(token)
145
+ validate_token(token)
146
+ rescue TokenFormatError
147
+ false
118
148
  end
119
149
 
120
150
  def self.validate_token_data(token_data)
121
151
  validation = TokenDataSchema.call(token_data)
122
152
 
123
- raise TokenDataFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
153
+ raise TokenDataFormatError, format_errors(validation) if validation.failure?
124
154
 
125
155
  true
126
156
  end
127
157
 
128
158
  def self.valid_token_data?(token_data)
129
- validate_token_data(token_data) rescue false
159
+ validate_token_data(token_data)
160
+ rescue TokenDataFormatError
161
+ false
130
162
  end
131
163
 
132
- def validate_content(now: Time.now)
133
- raise ReplayAttackError, "token signature time indicates a replay attack (age #{now-cms_signing_time})" unless signing_time_ok?(now: now)
134
-
135
- raise SignatureError unless valid_signature?
164
+ def self.format_errors(validation)
165
+ validation.errors.map{|key, msg| "#{key}: #{msg}"}.join('; ')
136
166
  end
137
167
  end
138
168
  end
@@ -1,3 +1,3 @@
1
1
  module Pedicel
2
- VERSION = '0.0.2'
2
+ VERSION = '0.0.4'.freeze
3
3
  end
data/lib/pedicel.rb CHANGED
@@ -9,38 +9,32 @@ module Pedicel
9
9
  class VersionError < Error; end
10
10
  class CertificateError < Error; end
11
11
  class EcKeyError < Error; end
12
+ class AesKeyError < Error; end
12
13
 
13
- DEFAULTS = {
14
- oids: {
15
- intermediate_certificate: '1.2.840.113635.100.6.2.14',
16
- leaf_certificate: '1.2.840.113635.100.6.29',
17
- merchant_identifier_field: '1.2.840.113635.100.6.32',
18
- },
19
- replay_threshold_seconds: 3*60,
20
- apple_root_ca_g3_cert_pem: <<~PEM
21
- -----BEGIN CERTIFICATE-----
22
- MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
23
- QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
24
- IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
25
- MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
26
- b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
27
- aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
28
- AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
29
- TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
30
- IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
31
- MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
32
- MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
33
- at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
34
- 6BgD56KyKA==
35
- -----END CERTIFICATE-----
36
- PEM
37
- }
14
+ APPLE_ROOT_CA_G3_CERT_PEM = <<~PEM
15
+ -----BEGIN CERTIFICATE-----
16
+ MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
17
+ QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
18
+ IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
19
+ MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
20
+ b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y
21
+ aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49
22
+ AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf
23
+ TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517
24
+ IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr
25
+ MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA
26
+ MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4
27
+ at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM
28
+ 6BgD56KyKA==
29
+ -----END CERTIFICATE-----
30
+ PEM
31
+ APPLE_ROOT_CA_G3_CERT_PEM.freeze
38
32
 
39
- def self.config
40
- @@config ||= DEFAULTS
41
- end
42
-
43
- def self.config=(other)
44
- @@config = other
45
- end
33
+ DEFAULT_CONFIG = {
34
+ oid_intermediate_certificate: '1.2.840.113635.100.6.2.14',
35
+ oid_leaf_certificate: '1.2.840.113635.100.6.29',
36
+ oid_merchant_identifier_field: '1.2.840.113635.100.6.32',
37
+ replay_threshold_seconds: 3 * 60,
38
+ trusted_ca_pem: APPLE_ROOT_CA_G3_CERT_PEM,
39
+ }.freeze
46
40
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pedicel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clearhaus
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-02-27 00:00:00.000000000 Z
11
+ date: 2018-06-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: 0.11.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: aes256gcm_decrypt
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '1.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '1.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '12.3'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '12.3'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rspec
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -39,19 +67,19 @@ dependencies:
39
67
  - !ruby/object:Gem::Version
40
68
  version: '3.7'
41
69
  - !ruby/object:Gem::Dependency
42
- name: pry
70
+ name: pedicel-pay
43
71
  requirement: !ruby/object:Gem::Requirement
44
72
  requirements:
45
73
  - - "~>"
46
74
  - !ruby/object:Gem::Version
47
- version: '0.11'
75
+ version: '0.0'
48
76
  type: :development
49
77
  prerelease: false
50
78
  version_requirements: !ruby/object:Gem::Requirement
51
79
  requirements:
52
80
  - - "~>"
53
81
  - !ruby/object:Gem::Version
54
- version: '0.11'
82
+ version: '0.0'
55
83
  description:
56
84
  email: hello@clearhaus.com
57
85
  executables: []
@@ -64,7 +92,7 @@ files:
64
92
  - lib/pedicel/rsa.rb
65
93
  - lib/pedicel/validator.rb
66
94
  - lib/pedicel/version.rb
67
- homepage: https://github.com/clearhaus/pedicel-pay
95
+ homepage: https://github.com/clearhaus/pedicel
68
96
  licenses:
69
97
  - MIT
70
98
  metadata: {}
@@ -76,7 +104,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
76
104
  requirements:
77
105
  - - "~>"
78
106
  - !ruby/object:Gem::Version
79
- version: '2.4'
107
+ version: '2.3'
80
108
  required_rubygems_version: !ruby/object:Gem::Requirement
81
109
  requirements:
82
110
  - - ">="
@@ -84,8 +112,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
112
  version: '0'
85
113
  requirements: []
86
114
  rubyforge_project:
87
- rubygems_version: 2.5.2
115
+ rubygems_version: 2.7.4
88
116
  signing_key:
89
117
  specification_version: 4
90
- summary: Backend and client part of Apple Pay
118
+ summary: Decryption of Apple Pay payment tokens
91
119
  test_files: []