pedicel 0.0.1 → 0.1.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
- SHA1:
3
- metadata.gz: 402a60cbe58a1a300764a3a37d53434c0e764f60
4
- data.tar.gz: 1551470f672d864db8fec34b25eb0521c59de87b
2
+ SHA256:
3
+ metadata.gz: 74bf3607219c5927eb2c3409abb203f3e8db743ee8d7e264f10b5c801fc8fde3
4
+ data.tar.gz: a1bc91e194a468eed00e996af0b149d784a801f3fa8608129696af26d1fc34bd
5
5
  SHA512:
6
- metadata.gz: 3ec3218ec7911fbf7e2cd3d11b5fb3fc839be0f8e51694565f7cef655fdebc0d5133a906ea0bfc215e21da4030281f6c7f5c202fc3332e67b98a108db5d3c0ab
7
- data.tar.gz: b770f9aff8ace1d06fda313fcdf9798bd84c9d78394b42539d22bbcd359db63956d201997477aa1e243b3c31fb936592db25cbd0d7cd29c79fdca47290258d91
6
+ metadata.gz: dd75b505a0eefefba6fa101e6fcd65e5274b495871a602f3f7b308df34a58ca9b10855b090344256658379c330e76a1658f002aa00e46c55bbe14f15c5f12087
7
+ data.tar.gz: c3cf9f84d1e0fb73bfe0241a305c9bec64a5e75f84ea4c42a95303c88873de3eadd0890d511ca6a3cf45dfc20897b87ecb6981d74f70c5122d3342aa2a1df275
@@ -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
@@ -1,105 +1,113 @@
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
+ validation = Validator::Token.new(token)
13
+ validation.validate
16
14
 
17
- raise SignatureError unless valid_signature?
15
+ @token = validation.output
16
+ @config = config
18
17
  end
19
18
 
20
19
  def version
21
- @token['version']&.to_sym
20
+ @token[:version].to_sym
22
21
  end
23
22
 
24
23
  def encrypted_data
25
- return nil unless @token['data']
24
+ return nil unless @token[:data]
26
25
 
27
- Base64.decode64(@token['data'])
26
+ Base64.decode64(@token[:data])
28
27
  end
29
28
 
30
29
  def signature
31
- return nil unless @token['signature']
30
+ return nil unless @token[:signature]
32
31
 
33
- Base64.decode64(@token['signature'])
32
+ Base64.decode64(@token[:signature])
34
33
  end
35
34
 
36
35
  def transaction_id
37
- [@token['header']['transactionId']].pack('H*')
36
+ [@token[:header][:transactionId]].pack('H*')
38
37
  end
39
38
 
40
39
  def application_data
41
- return nil unless @token['applicationData']
40
+ return nil unless @token[:header][:applicationData]
42
41
 
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
51
-
52
- delta = Pedicel.config[:replay_threshold_seconds]
53
-
54
- cms_signing_time.between?(now - delta, now + delta)
55
- # Deliberately ignoring leap seconds.
42
+ [@token[:header][:applicationData]].pack('H*')
56
43
  end
57
44
 
58
45
  def private_key_class
59
- {EC_v1: OpenSSL::PKey::EC, RSA_v1: OpenSSL::PKey::RSA}[version]
46
+ raise VersionError, "unsupported version: #{version}" unless SUPPORTED_VERSIONS.include?(version)
47
+
48
+ { EC_v1: OpenSSL::PKey::EC, RSA_v1: OpenSSL::PKey::RSA }[version]
60
49
  end
61
50
 
62
51
  def symmetric_algorithm
63
- {EC_v1: 'aes-256-gcm', RSA_v1: 'aes-128-gcm'}[version]
52
+ raise VersionError, "unsupported version: #{version}" unless SUPPORTED_VERSIONS.include?(version)
53
+
54
+ { EC_v1: 'aes-256-gcm', RSA_v1: 'aes-128-gcm' }[version]
64
55
  end
65
56
 
66
57
  def decrypt_aes(key:)
67
58
  raise TokenFormatError, 'no encrypted data present' unless encrypted_data
68
59
 
69
60
  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.
61
+ # Either because you use Ruby >=2.4's native openssl lib, or if you have
62
+ # a "recent enough" version of the openssl gem available.
63
+ decrypt_aes_openssl(key)
64
+ else
65
+ decrypt_aes_gem(key)
66
+ end
67
+ end
72
68
 
73
- cipher = OpenSSL::Cipher.new(symmetric_algorithm)
74
- cipher.decrypt
69
+ private 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
+ private 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
95
103
 
96
104
  def valid_signature?(now: Time.now)
97
- true if validate_signature(now: now)
105
+ !!verify_signature(now: now)
98
106
  rescue
99
107
  false
100
108
  end
101
109
 
102
- def verify_signature(ca_certificate_pem: Pedicel.config[:apple_root_ca_g3_cert_pem], now: Time.now)
110
+ def verify_signature(ca_certificate_pem: @config[:trusted_ca_pem], now: Time.now)
103
111
  raise SignatureError, 'no signature present' unless signature
104
112
 
105
113
  begin
@@ -108,24 +116,36 @@ module Pedicel
108
116
  raise SignatureError, "invalid PKCS #7 signature: #{e.message}"
109
117
  end
110
118
 
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
119
  begin
117
- root = OpenSSL::X509::Certificate.new(ca_certificate_pem)
120
+ trusted_root = OpenSSL::X509::Certificate.new(ca_certificate_pem)
118
121
  rescue => e
119
- raise CertificateError, "invalid root certificate: #{e.message}"
122
+ raise CertificateError, "invalid trusted root certificate: #{e.message}"
120
123
  end
121
124
 
125
+ # 1.a
126
+ # Ensure that the certificates contain the correct custom OIDs: (...).
127
+ # The value for these marker OIDs doesn't matter, only their presence.
128
+ leaf, intermediate, other = self.class.extract_certificates(signature: s,
129
+ intermediate_oid: @config[:oid_intermediate_certificate],
130
+ leaf_oid: @config[:oid_leaf_certificate])
131
+ # Implicit since these are the ones extracted.
132
+
122
133
  # 1.b
123
134
  # Ensure that the root CA is the Apple Root CA - G3. (...)
124
- self.class.verify_root_certificate(root: root, intermediate: intermediate)
135
+ if other
136
+ self.class.verify_root_certificate(trusted_root: trusted_root, root: other)
137
+ # Allow no other certificate than the root.
138
+ #else
139
+ # no other certificate is not extracted from the signature, and thus, we
140
+ # trust the trusted root.
141
+ end
125
142
 
126
143
  # 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)
144
+ # Ensure that there is a valid X.509 chain of trust from the signature to
145
+ # the root CA.
146
+ self.class.verify_x509_chain(root: trusted_root, intermediate: intermediate, leaf: leaf)
147
+ # We "only" check the *certificate* chain (from leaf to root). Below (in
148
+ # 1.d) is checked that the signature is created with the leaf.
129
149
 
130
150
  # 1.d
131
151
  # Validate the token's signature.
@@ -135,63 +155,87 @@ module Pedicel
135
155
 
136
156
  # 1.e
137
157
  # Inspect the CMS signing time of the signature (...)
138
- self.class.verify_signed_time(signature: s, now: now)
158
+ self.class.verify_signed_time(signature: s, now: now, few_min: @config[:replay_threshold_seconds])
139
159
 
140
160
  self
141
161
  end
142
162
 
143
- private
163
+ def self.extract_certificates(signature:,
164
+ intermediate_oid: Pedicel::DEFAULT_CONFIG[:oid_intermediate_certificate],
165
+ leaf_oid: Pedicel::DEFAULT_CONFIG[:oid_leaf_certificate])
166
+ leafs, intermediates, others = [], [], []
144
167
 
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
168
+ signature.certificates.each do |certificate|
169
+ leaf_or_intermediate = false
150
170
 
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]})"
171
+ certificate.extensions.each do |extension|
172
+ case extension.oid
173
+ when intermediate_oid
174
+ intermediates << certificate
175
+ leaf_or_intermediate = true
176
+ when leaf_oid
177
+ leafs << certificate
178
+ leaf_or_intermediate = true
179
+ end
180
+ end
181
+
182
+ others << certificate unless leaf_or_intermediate
154
183
  end
155
184
 
156
- [leaf, intermediate]
185
+ raise SignatureError, "no unique leaf certificate found (OID #{leaf_oid})" unless leafs.length == 1
186
+ raise SignatureError, "no unique intermediate certificate found (OID #{intermediate_oid})" unless intermediates.length == 1
187
+ raise SignatureError, "too many certificates found in the signature: #{others.map(&:subject).join('; ')}" if others.length > 1
188
+
189
+ [leafs.first, intermediates.first, others.first]
157
190
  end
158
191
 
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
192
+ def self.verify_root_certificate(root:, trusted_root:)
193
+ raise SignatureError, 'root certificate is not trusted' unless root.to_der == trusted_root.to_der
194
+
195
+ true
163
196
  end
164
197
 
165
198
  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)
199
+ # Specifically, ensure that the signature was created using the private
200
+ # key corresponding to the leaf certificate, that the leaf certificate is
201
+ # signed by the intermediate CA, and that the intermediate CA is signed by
202
+ # the Apple Root CA - G3.
203
+
204
+ unless root.verify(root.public_key)
205
+ raise SignatureError, 'invalid chain due to root'
206
+ end
207
+
208
+ unless intermediate.verify(root.public_key)
209
+ raise SignatureError, 'invalid chain due to intermediate'
210
+ end
211
+
212
+ unless leaf.verify(intermediate.public_key)
213
+ raise SignatureError, 'invalid chain due to leaf'
214
+ end
170
215
 
171
- raise SignatureError, 'invalid chain of trust' unless valid_chain
216
+ true
172
217
  end
173
218
 
174
- def self.verify_signed_time(signature:, now:)
219
+ def self.verify_signed_time(signature:, now: Time.now, few_min: Pedicel::DEFAULT_CONFIG[:replay_threshold_seconds])
175
220
  # Inspect the CMS signing time of the signature, as defined by section
176
221
  # 11.3 of RFC 5652. If the time signature and the transaction time differ
177
222
  # by more than a few minutes, it's possible that the token is a replay
178
223
  # attack.
224
+ # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
179
225
 
180
226
  unless signature.signers.length == 1
181
227
  raise SignatureError, 'not 1 signer, unable to determine signing time'
182
228
  end
183
229
  signed_time = signature.signers.first.signed_time
184
230
 
185
- few_min = Pedicel.config[:replay_threshold_seconds]
186
-
187
- # Time objects. DST aware. Ignoring leap seconds.
188
- return if signed_time.between?(now - few_min, now + few_min) # Both ends included.
231
+ # Time objects. DST aware. Ignoring leap seconds. Both ends included.
232
+ return true if signed_time.between?(now - few_min, now + few_min)
189
233
 
190
234
  diff = signed_time - now
191
235
  if diff.negative?
192
- raise SignatureError, "signature too old; signed #{-diff.to_i}s ago"
236
+ raise SignatureError, "signature too old; signed #{-diff}s ago"
193
237
  end
194
- raise SignatureError, "signature too new; signed #{diff.to_i}s in the future"
238
+ raise SignatureError, "signature too new; signed #{diff}s in the future"
195
239
  end
196
240
  end
197
241
  end
@@ -3,51 +3,48 @@ require 'pedicel/base'
3
3
  module Pedicel
4
4
  class EC < Base
5
5
  def ephemeral_public_key
6
- Base64.decode64(@token['header']['ephemeralPublicKey'])
6
+ Base64.decode64(@token[:header][:ephemeralPublicKey])
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, mid_oid: @config[:oid_merchant_identifier_field])
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:, mid_oid: Pedicel::DEFAULT_CONFIG[:oid_merchant_identifier_field])
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 == mid_oid }
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,78 +1,111 @@
1
1
  require 'dry-validation'
2
2
  require 'base64'
3
+ require 'openssl'
3
4
 
4
5
  module Pedicel
5
- class Validator
6
- class Error < StandardError; end
7
- class TokenFormatError < Error; end
8
- class TokenDataFormatError < Error; end
9
6
 
10
- DRY_CUSTOM_PREDICATE_ERRORS = {
11
- base64?: 'invalid base64',
12
- hex?: 'invalid hex',
13
- pan?: 'invalid pan',
14
- yymmdd?: 'invalid date format YYMMDD',
15
- }
7
+ # Validations for Apple Pay Payment Token and associated data:
8
+ # https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
9
+ # This purposefully only does syntactic validation (as opposed to semantic).
10
+ module Validator
16
11
 
17
12
  module Predicates
18
13
  include Dry::Logic::Predicates
19
14
 
20
- predicate(:base64?) { |value| !Base64.decode64(value).nil? rescue false }
15
+ CUSTOM_PREDICATE_ERRORS = {
16
+ base64?: 'must be Base64',
17
+ hex?: 'must be hex',
18
+ pan?: 'must be a pan',
19
+ yymmdd?: 'must be formatted YYMMDD',
20
+ ec_public_key?: 'must be an EC public key',
21
+ pkcs7_signature?: 'must be a PKCS7 Signature',
22
+ eci?: 'must be an ECI',
23
+ hex_sha256?: 'must be a hex-encoded SHA-256',
24
+ base64_sha256?: 'must be a Base64-encoded SHA-256',
25
+ iso4217_numeric?: 'must be an ISO 4217 numeric code',
26
+ }.freeze
27
+
28
+ # Support Ruby 2.3, but use the faster #match? when available.
29
+ match_b = String.new.respond_to?(:match?) ? ->(s, re) { s.match?(re) } : ->(s, re) { !!(s =~ re) }
30
+
31
+ predicate(:base64?) do |x|
32
+ str?(x) &&
33
+ match_b.(x, /\A[=A-Za-z0-9+\/]*\z/) && # allowable chars
34
+ x.length.remainder(4).zero? && # multiple of 4
35
+ !match_b.(x, /=[^$=]/) && # may only end with ='s
36
+ !match_b.(x, /===/) # at most 2 ='s
37
+ end
21
38
 
22
- predicate(:hex?) { |value| !Regexp.new(/\A[a-f0-9-]*\z/i).match(value).nil? }
39
+ # We should figure out how strict we should be. Hopefully we can discard
40
+ # the above Base64? predicate and use the following simpler one:
41
+ #predicate(:strict_base64?) { |x| !!Base64.strict_decode64(x) rescue false }
23
42
 
24
- predicate(:pan?) { |value| !Regexp.new(/\A[0-9]{13,19}\z/).match(value).nil? }
43
+ predicate(:base64_sha256?) { |x| base64?(x) && Base64.decode64(x).length == 32 }
25
44
 
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
45
+ predicate(:hex?) { |x| str?(x) && match_b.(x, /\A[a-f0-9]*\z/i) }
46
+
47
+ predicate(:hex_sha256?) { |x| hex?(x) && x.length == 64 }
48
+
49
+ predicate(:pan?) { |x| str?(x) && match_b.(x, /\A[1-9][0-9]{11,18}\z/) }
50
+
51
+ predicate(:yymmdd?) { |x| str?(x) && match_b.(x, /\A\d{6}\z/) }
52
+
53
+ predicate(:eci?) { |x| str?(x) && match_b.(x, /\A\d{1,2}\z/) }
54
+
55
+ predicate(:ec_public_key?) { |x| base64?(x) && OpenSSL::PKey::EC.new(Base64.decode64(x)).check_key rescue false }
56
+
57
+ predicate(:pkcs7_signature?) { |x| base64?(x) && !!OpenSSL::PKCS7.new(Base64.decode64(x)) rescue false }
58
+
59
+ predicate(:iso4217_numeric?) { |x| match_b.(x, /\A[0-9]{3}\z/) }
30
60
  end
31
61
 
32
- TokenSchema = Dry::Validation.Schema do
33
- configure do
34
- predicates(Predicates)
35
- def self.messages
36
- super.merge(en: { errors: DRY_CUSTOM_PREDICATE_ERRORS })
37
- end
62
+ class BaseSchema < Dry::Validation::Schema::JSON
63
+ predicates(Predicates)
64
+ def self.messages
65
+ super.merge(en: { errors: Predicates::CUSTOM_PREDICATE_ERRORS })
38
66
  end
67
+ end
39
68
 
40
- required(:data).filled(:str?, :base64?)
41
-
42
- required(:header).schema do
43
- optional(:applicationData).filled(:str?, :hex?)
69
+ TokenHeaderSchema = Dry::Validation.Schema(BaseSchema) do
70
+ optional(:applicationData).filled(:str?, :hex?, :hex_sha256?)
44
71
 
45
- optional(:ephemeralPublicKey).filled(:str?, :base64?)
46
- optional(:wrappedKey).filled(:str?, :base64?)
47
- rule('ephemeralPublicKey xor wrappedKey': [:ephemeralPublicKey, :wrappedKey]) do |e, w|
48
- e.filled? ^ w.filled?
49
- end
72
+ optional(:ephemeralPublicKey).filled(:str?, :base64?, :ec_public_key?)
50
73
 
51
- required(:publicKeyHash).filled(:str?, :base64?)
74
+ optional(:wrappedKey).filled(:str?, :base64?)
52
75
 
53
- required(:transactionId).filled(:str?, :hex?)
76
+ rule('ephemeralPublicKey xor wrappedKey': [:ephemeralPublicKey, :wrappedKey]) do |e, w|
77
+ e.filled? ^ w.filled?
54
78
  end
55
79
 
56
- required(:signature).filled(:str?, :base64?)
80
+ required(:publicKeyHash).filled(:str?, :base64?, :base64_sha256?)
57
81
 
58
- required(:version).filled(:str?, included_in?: ['EC_v1', 'RSA_v1'])
82
+ required(:transactionId).filled(:str?, :hex?)
59
83
  end
60
84
 
61
- # Pedicel::Validator::TokenSchema.call({data: 'asdf', header: {ephemeralPublicKey: 'e', publicKeyHash: 'p', transactionId: 'f'}, signature: 's', version: 'EC_v1'})
85
+ TokenSchema = Dry::Validation.Schema(BaseSchema) do
86
+ required(:data).filled(:str?, :base64?)
62
87
 
63
- TokenDataSchema = Dry::Validation.Schema do
64
- configure do
65
- predicates(Predicates)
66
- def self.messages
67
- super.merge(en: { errors: DRY_CUSTOM_PREDICATE_ERRORS })
68
- end
69
- end
88
+ required(:header).schema(TokenHeaderSchema)
89
+
90
+ required(:signature).filled(:str?, :base64?, :pkcs7_signature?)
91
+
92
+ required(:version).filled(:str?, included_in?: %w[EC_v1 RSA_v1])
93
+ end
94
+
95
+ TokenDataPaymentDataSchema = Dry::Validation.Schema(BaseSchema) do
96
+ optional(:onlinePaymentCryptogram).filled(:str?, :base64?)
97
+ optional(:eciIndicator).filled(:str?, :eci?)
70
98
 
99
+ optional(:emvData).filled(:str?, :base64?)
100
+ optional(:encryptedPINData).filled(:str?, :hex?)
101
+ end
102
+
103
+ TokenDataSchema = Dry::Validation.Schema(BaseSchema) do
71
104
  required(:applicationPrimaryAccountNumber).filled(:str?, :pan?)
72
105
 
73
106
  required(:applicationExpirationDate).filled(:str?, :yymmdd?)
74
107
 
75
- required(:currencyCode).filled(:str?, format?: /\A[0-9]{3}\z/)
108
+ required(:currencyCode).filled(:str?, :iso4217_numeric?)
76
109
 
77
110
  required(:transactionAmount).filled(:int?)
78
111
 
@@ -80,59 +113,89 @@ module Pedicel
80
113
 
81
114
  required(:deviceManufacturerIdentifier).filled(:str?, :hex?)
82
115
 
83
- required(:paymentDataType).filled(:str?, included_in?: ['3DSecure', 'EMV'])
116
+ required(:paymentDataType).filled(:str?, included_in?: %w[3DSecure EMV])
84
117
 
85
- required(:paymentData).schema do
86
- optional(:onlinePaymentCryptogram).filled(:str?, :base64?)
87
- optional(:eciIndicator).filled(:str?)
118
+ required(:paymentData).schema(TokenDataPaymentDataSchema)
88
119
 
89
- optional(:emvData).filled(:str?, :base64?)
90
- optional(:encryptedPINData).filled(:str?, :hex?)
120
+ rule('when paymentDataType is 3DSecure, onlinePaymentCryptogram': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |type, cryptogram|
121
+ type.eql?('3DSecure') > cryptogram.filled?
122
+ end
123
+ rule('when paymentDataType is 3DSecure, emvData': [:paymentDataType, [:paymentData, :emvData]]) do |type, emv|
124
+ type.eql?('3DSecure') > emv.none?
125
+ end
126
+ rule('when paymentDataType is 3DSecure, encryptedPINData': [:paymentDataType, [:paymentData, :encryptedPINData]]) do |type, pin|
127
+ type.eql?('3DSecure') > pin.none?
91
128
  end
92
129
 
93
- rule('consistent paymentDataType and paymentData': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |t, cryptogram|
94
- t.eql?('3DSecure') > cryptogram.filled?
130
+ rule('when paymentDataType is EMV, onlinePaymentCryptogram': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |type, cryptogram|
131
+ type.eql?('EMV') > cryptogram.none?
132
+ end
133
+ rule('when paymentDataType is EMV, eciIndicator': [:paymentDataType, [:paymentData, :eciIndicator]]) do |type, eci|
134
+ type.eql?('EMV') > eci.none?
135
+ end
136
+ rule('when paymentDataType is EMV, emvData': [:paymentDataType, [:paymentData, :emvData]]) do |type, emv|
137
+ type.eql?('EMV') > emv.filled?
95
138
  end
139
+ rule('when paymentDataType is EMV, encryptedPINData': [:paymentDataType, [:paymentData, :encryptedPINData]]) do |type, pin|
140
+ type.eql?('EMV') > pin.filled?
141
+ end
142
+
96
143
  end
97
144
 
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
- # )
145
+ class Error < StandardError; end
107
146
 
108
- def self.validate_token(token, now: Time.now)
109
- validation = TokenSchema.call(token)
147
+ module InstanceMethods
148
+ attr_reader :output
110
149
 
111
- raise TokenFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
150
+ def validate
151
+ @validation ||= @schema.call(@input)
112
152
 
113
- true
114
- end
153
+ @output = @validation.output
115
154
 
116
- def self.valid_token?(token, now: Time.now)
117
- validate_token(token, now: now) rescue false
118
- end
155
+ return true if @validation.success?
119
156
 
120
- def self.validate_token_data(token_data)
121
- validation = TokenDataSchema.call(token_data)
157
+ raise Error, "validation error: #{@validation.errors.keys.join(', ')}"
158
+ end
122
159
 
123
- raise TokenDataFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
160
+ def valid?
161
+ validate
162
+ rescue Error
163
+ false
164
+ end
124
165
 
125
- true
126
- end
166
+ def errors
167
+ valid? unless @validation
168
+
169
+ @validation.errors
170
+ end
127
171
 
128
- def self.valid_token_data?(token_data)
129
- validate_token_data(token_data) rescue false
172
+ def errors_formatted(node = [errors])
173
+ node.pop.flat_map do |key, value|
174
+ if value.is_a?(Array)
175
+ value.map { |error| "#{(node + [key]).join('.')} #{error}" }
176
+ else
177
+ errors_formatted(node + [key, value])
178
+ end
179
+ end
180
+ end
130
181
  end
131
182
 
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)
183
+ class Token
184
+ include InstanceMethods
185
+ class Error < ::Pedicel::Validator::Error; end
186
+ def initialize(input)
187
+ @input = input
188
+ @schema = TokenSchema
189
+ end
190
+ end
134
191
 
135
- raise SignatureError unless valid_signature?
192
+ class TokenData
193
+ include InstanceMethods
194
+ class Error < ::Pedicel::Validator::Error; end
195
+ def initialize(input)
196
+ @input = input
197
+ @schema = TokenDataSchema
198
+ end
136
199
  end
137
200
  end
138
201
  end
@@ -0,0 +1,3 @@
1
+ module Pedicel
2
+ VERSION = '0.1.0'.freeze
3
+ 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.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clearhaus
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-02-27 00:00:00.000000000 Z
11
+ date: 2020-11-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-validation
@@ -25,19 +25,19 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: 0.11.1
27
27
  - !ruby/object:Gem::Dependency
28
- name: aes256gcm_decrypt
28
+ name: rake
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: 0.0.2
34
- type: :runtime
33
+ version: '12.3'
34
+ type: :development
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: 0.0.2
40
+ version: '12.3'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: rspec
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -53,20 +53,20 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '3.7'
55
55
  - !ruby/object:Gem::Dependency
56
- name: pry
56
+ name: pedicel-pay
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: '0.11'
61
+ version: '0.0'
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.11'
69
- description:
68
+ version: '0.0'
69
+ description:
70
70
  email: hello@clearhaus.com
71
71
  executables: []
72
72
  extensions: []
@@ -77,28 +77,28 @@ files:
77
77
  - lib/pedicel/ec.rb
78
78
  - lib/pedicel/rsa.rb
79
79
  - lib/pedicel/validator.rb
80
- homepage: https://github.com/clearhaus/pedicel-pay
80
+ - lib/pedicel/version.rb
81
+ homepage: https://github.com/clearhaus/pedicel
81
82
  licenses:
82
83
  - MIT
83
84
  metadata: {}
84
- post_install_message:
85
+ post_install_message:
85
86
  rdoc_options: []
86
87
  require_paths:
87
88
  - lib
88
89
  required_ruby_version: !ruby/object:Gem::Requirement
89
90
  requirements:
90
- - - ">="
91
+ - - "~>"
91
92
  - !ruby/object:Gem::Version
92
- version: '0'
93
+ version: 2.5.5
93
94
  required_rubygems_version: !ruby/object:Gem::Requirement
94
95
  requirements:
95
96
  - - ">="
96
97
  - !ruby/object:Gem::Version
97
98
  version: '0'
98
99
  requirements: []
99
- rubyforge_project:
100
- rubygems_version: 2.5.2
101
- signing_key:
100
+ rubygems_version: 3.0.8
101
+ signing_key:
102
102
  specification_version: 4
103
- summary: Backend and client part of Apple Pay
103
+ summary: Decryption of Apple Pay payment tokens
104
104
  test_files: []