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 +5 -5
- data/lib/pedicel/base.rb +131 -79
- data/lib/pedicel/ec.rb +46 -45
- data/lib/pedicel/validator.rb +86 -56
- data/lib/pedicel/version.rb +1 -1
- data/lib/pedicel.rb +26 -32
- metadata +37 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 628cf5b61cec879b91695fdbbf8f9c3a0803a9c7be2645076d34da772f030b86
|
4
|
+
data.tar.gz: 04dd8ff38fe186fdf5f79a2c648a9482e63add4df9ac2b42aa2b229b2c4ae3fa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
11
|
+
def initialize(token, config: Pedicel::DEFAULT_CONFIG)
|
12
|
+
Validator.validate_token(token)
|
16
13
|
|
17
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
74
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
75
|
+
rescue ArgumentError => e
|
76
|
+
raise Pedicel::AesKeyError, "invalid key: #{e.message}"
|
77
|
+
end
|
80
78
|
|
81
|
-
|
82
|
-
|
83
|
-
|
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
|
-
|
86
|
-
|
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
|
-
|
89
|
-
|
90
|
-
require 'aes256gcm_decrypt'
|
88
|
+
cipher.auth_tag = tag
|
89
|
+
cipher.auth_data = ''.b
|
91
90
|
|
92
|
-
|
93
|
-
|
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
|
-
|
106
|
+
!!verify_signature(now: now)
|
98
107
|
rescue
|
99
108
|
false
|
100
109
|
end
|
101
110
|
|
102
|
-
def verify_signature(ca_certificate_pem:
|
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
|
-
|
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
|
-
|
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
|
128
|
-
|
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
|
-
|
162
|
+
def self.extract_certificates(signature:, config: Pedicel::DEFAULT_CONFIG)
|
163
|
+
leafs, intermediates, others = [], [], []
|
144
164
|
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
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
|
-
|
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:,
|
160
|
-
unless
|
161
|
-
|
162
|
-
|
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
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
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 =
|
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)
|
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
|
244
|
+
raise SignatureError, "signature too old; signed #{-diff}s ago"
|
193
245
|
end
|
194
|
-
raise SignatureError, "signature too new; signed #{diff
|
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:
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
#
|
17
|
-
|
18
|
-
|
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(
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
#
|
39
|
-
|
40
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
data/lib/pedicel/validator.rb
CHANGED
@@ -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
|
-
|
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(:
|
44
|
+
predicate(:base64_sha256?) { |x| base64?(x) && Base64.decode64(x).length == 32 }
|
23
45
|
|
24
|
-
predicate(:
|
46
|
+
predicate(:hex?) { |x| str?(x) && match_b.(x, /\A[a-f0-9]*\z/i) }
|
25
47
|
|
26
|
-
predicate(:
|
27
|
-
|
28
|
-
|
29
|
-
|
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:
|
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?: [
|
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:
|
105
|
+
super.merge(en: { errors: Predicates::CUSTOM_PREDICATE_ERRORS })
|
68
106
|
end
|
69
107
|
end
|
70
108
|
|
71
|
-
required(
|
109
|
+
required('applicationPrimaryAccountNumber').filled(:str?, :pan?)
|
72
110
|
|
73
|
-
required(
|
111
|
+
required('applicationExpirationDate').filled(:str?, :yymmdd?)
|
74
112
|
|
75
|
-
required(
|
113
|
+
required('currencyCode').filled(:str?, format?: /\A[0-9]{3}\z/)
|
76
114
|
|
77
|
-
required(
|
115
|
+
required('transactionAmount').filled(:int?)
|
78
116
|
|
79
|
-
optional(
|
117
|
+
optional('cardholderName').filled(:str?)
|
80
118
|
|
81
|
-
required(
|
119
|
+
required('deviceManufacturerIdentifier').filled(:str?, :hex?)
|
82
120
|
|
83
|
-
required(
|
121
|
+
required('paymentDataType').filled(:str?, included_in?: %w[3DSecure EMV])
|
84
122
|
|
85
|
-
required(
|
86
|
-
optional(
|
87
|
-
optional(
|
123
|
+
required('paymentData').schema do
|
124
|
+
optional('onlinePaymentCryptogram').filled(:str?, :base64?)
|
125
|
+
optional('eciIndicator').filled(:str?, :eci?)
|
88
126
|
|
89
|
-
optional(
|
90
|
-
optional(
|
127
|
+
optional('emvData').filled(:str?, :base64?)
|
128
|
+
optional('encryptedPINData').filled(:str?, :hex?)
|
91
129
|
end
|
92
130
|
|
93
|
-
rule('
|
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
|
-
|
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
|
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
|
117
|
-
validate_token(token
|
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
|
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)
|
159
|
+
validate_token_data(token_data)
|
160
|
+
rescue TokenDataFormatError
|
161
|
+
false
|
130
162
|
end
|
131
163
|
|
132
|
-
def
|
133
|
-
|
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
|
data/lib/pedicel/version.rb
CHANGED
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
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
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.
|
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-
|
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:
|
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.
|
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.
|
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
|
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.
|
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.
|
115
|
+
rubygems_version: 2.7.4
|
88
116
|
signing_key:
|
89
117
|
specification_version: 4
|
90
|
-
summary:
|
118
|
+
summary: Decryption of Apple Pay payment tokens
|
91
119
|
test_files: []
|