pedicel 0.0.1 → 0.1.0

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