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 +5 -5
- data/lib/pedicel.rb +26 -32
- data/lib/pedicel/base.rb +131 -87
- data/lib/pedicel/ec.rb +47 -46
- data/lib/pedicel/validator.rb +143 -80
- data/lib/pedicel/version.rb +3 -0
- metadata +19 -19
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 74bf3607219c5927eb2c3409abb203f3e8db743ee8d7e264f10b5c801fc8fde3
|
4
|
+
data.tar.gz: a1bc91e194a468eed00e996af0b149d784a801f3fa8608129696af26d1fc34bd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: dd75b505a0eefefba6fa101e6fcd65e5274b495871a602f3f7b308df34a58ca9b10855b090344256658379c330e76a1658f002aa00e46c55bbe14f15c5f12087
|
7
|
+
data.tar.gz: c3cf9f84d1e0fb73bfe0241a305c9bec64a5e75f84ea4c42a95303c88873de3eadd0890d511ca6a3cf45dfc20897b87ecb6981d74f70c5122d3342aa2a1df275
|
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
|
data/lib/pedicel/base.rb
CHANGED
@@ -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
|
-
|
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
|
+
validation = Validator::Token.new(token)
|
13
|
+
validation.validate
|
16
14
|
|
17
|
-
|
15
|
+
@token = validation.output
|
16
|
+
@config = config
|
18
17
|
end
|
19
18
|
|
20
19
|
def version
|
21
|
-
@token[
|
20
|
+
@token[:version].to_sym
|
22
21
|
end
|
23
22
|
|
24
23
|
def encrypted_data
|
25
|
-
return nil unless @token[
|
24
|
+
return nil unless @token[:data]
|
26
25
|
|
27
|
-
Base64.decode64(@token[
|
26
|
+
Base64.decode64(@token[:data])
|
28
27
|
end
|
29
28
|
|
30
29
|
def signature
|
31
|
-
return nil unless @token[
|
30
|
+
return nil unless @token[:signature]
|
32
31
|
|
33
|
-
Base64.decode64(@token[
|
32
|
+
Base64.decode64(@token[:signature])
|
34
33
|
end
|
35
34
|
|
36
35
|
def transaction_id
|
37
|
-
[@token[
|
36
|
+
[@token[:header][:transactionId]].pack('H*')
|
38
37
|
end
|
39
38
|
|
40
39
|
def application_data
|
41
|
-
return nil unless @token[
|
40
|
+
return nil unless @token[:header][:applicationData]
|
42
41
|
|
43
|
-
[@token[
|
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
|
-
|
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
|
-
|
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
|
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
|
-
|
74
|
-
|
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
|
-
|
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
|
+
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
|
-
|
105
|
+
!!verify_signature(now: now)
|
98
106
|
rescue
|
99
107
|
false
|
100
108
|
end
|
101
109
|
|
102
|
-
def verify_signature(ca_certificate_pem:
|
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
|
-
|
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
|
-
|
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
|
128
|
-
|
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
|
-
|
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
|
-
|
146
|
-
|
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
|
-
|
152
|
-
|
153
|
-
|
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
|
-
|
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:,
|
160
|
-
unless
|
161
|
-
|
162
|
-
|
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
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
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
|
-
|
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
|
-
|
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
|
236
|
+
raise SignatureError, "signature too old; signed #{-diff}s ago"
|
193
237
|
end
|
194
|
-
raise SignatureError, "signature too new; signed #{diff
|
238
|
+
raise SignatureError, "signature too new; signed #{diff}s in the future"
|
195
239
|
end
|
196
240
|
end
|
197
241
|
end
|
data/lib/pedicel/ec.rb
CHANGED
@@ -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[
|
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:
|
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, 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
|
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:, 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
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
data/lib/pedicel/validator.rb
CHANGED
@@ -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
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
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
|
-
|
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
|
-
|
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(:
|
43
|
+
predicate(:base64_sha256?) { |x| base64?(x) && Base64.decode64(x).length == 32 }
|
25
44
|
|
26
|
-
predicate(:
|
27
|
-
|
28
|
-
|
29
|
-
|
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
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
74
|
+
optional(:wrappedKey).filled(:str?, :base64?)
|
52
75
|
|
53
|
-
|
76
|
+
rule('ephemeralPublicKey xor wrappedKey': [:ephemeralPublicKey, :wrappedKey]) do |e, w|
|
77
|
+
e.filled? ^ w.filled?
|
54
78
|
end
|
55
79
|
|
56
|
-
required(:
|
80
|
+
required(:publicKeyHash).filled(:str?, :base64?, :base64_sha256?)
|
57
81
|
|
58
|
-
required(:
|
82
|
+
required(:transactionId).filled(:str?, :hex?)
|
59
83
|
end
|
60
84
|
|
61
|
-
|
85
|
+
TokenSchema = Dry::Validation.Schema(BaseSchema) do
|
86
|
+
required(:data).filled(:str?, :base64?)
|
62
87
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
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?,
|
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?: [
|
116
|
+
required(:paymentDataType).filled(:str?, included_in?: %w[3DSecure EMV])
|
84
117
|
|
85
|
-
required(:paymentData).schema
|
86
|
-
optional(:onlinePaymentCryptogram).filled(:str?, :base64?)
|
87
|
-
optional(:eciIndicator).filled(:str?)
|
118
|
+
required(:paymentData).schema(TokenDataPaymentDataSchema)
|
88
119
|
|
89
|
-
|
90
|
-
|
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('
|
94
|
-
|
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
|
-
|
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
|
-
|
109
|
-
|
147
|
+
module InstanceMethods
|
148
|
+
attr_reader :output
|
110
149
|
|
111
|
-
|
150
|
+
def validate
|
151
|
+
@validation ||= @schema.call(@input)
|
112
152
|
|
113
|
-
|
114
|
-
end
|
153
|
+
@output = @validation.output
|
115
154
|
|
116
|
-
|
117
|
-
validate_token(token, now: now) rescue false
|
118
|
-
end
|
155
|
+
return true if @validation.success?
|
119
156
|
|
120
|
-
|
121
|
-
|
157
|
+
raise Error, "validation error: #{@validation.errors.keys.join(', ')}"
|
158
|
+
end
|
122
159
|
|
123
|
-
|
160
|
+
def valid?
|
161
|
+
validate
|
162
|
+
rescue Error
|
163
|
+
false
|
164
|
+
end
|
124
165
|
|
125
|
-
|
126
|
-
|
166
|
+
def errors
|
167
|
+
valid? unless @validation
|
168
|
+
|
169
|
+
@validation.errors
|
170
|
+
end
|
127
171
|
|
128
|
-
|
129
|
-
|
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
|
-
|
133
|
-
|
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
|
-
|
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
|
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.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:
|
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:
|
28
|
+
name: rake
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
30
30
|
requirements:
|
31
31
|
- - "~>"
|
32
32
|
- !ruby/object:Gem::Version
|
33
|
-
version:
|
34
|
-
type: :
|
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:
|
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:
|
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.
|
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.
|
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
|
-
|
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:
|
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
|
-
|
100
|
-
|
101
|
-
signing_key:
|
100
|
+
rubygems_version: 3.0.8
|
101
|
+
signing_key:
|
102
102
|
specification_version: 4
|
103
|
-
summary:
|
103
|
+
summary: Decryption of Apple Pay payment tokens
|
104
104
|
test_files: []
|