pedicel 0.0.2 → 0.0.4

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
- 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: []