pedicel 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/lib/pedicel.rb +46 -0
- data/lib/pedicel/base.rb +197 -0
- data/lib/pedicel/ec.rb +168 -0
- data/lib/pedicel/rsa.rb +15 -0
- data/lib/pedicel/validator.rb +138 -0
- metadata +104 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 402a60cbe58a1a300764a3a37d53434c0e764f60
|
4
|
+
data.tar.gz: 1551470f672d864db8fec34b25eb0521c59de87b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 3ec3218ec7911fbf7e2cd3d11b5fb3fc839be0f8e51694565f7cef655fdebc0d5133a906ea0bfc215e21da4030281f6c7f5c202fc3332e67b98a108db5d3c0ab
|
7
|
+
data.tar.gz: b770f9aff8ace1d06fda313fcdf9798bd84c9d78394b42539d22bbcd359db63956d201997477aa1e243b3c31fb936592db25cbd0d7cd29c79fdca47290258d91
|
data/lib/pedicel.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'pedicel/base'
|
2
|
+
require 'pedicel/ec'
|
3
|
+
require 'pedicel/rsa'
|
4
|
+
|
5
|
+
module Pedicel
|
6
|
+
class Error < StandardError; end
|
7
|
+
class TokenFormatError < Error; end
|
8
|
+
class SignatureError < Error; end
|
9
|
+
class VersionError < Error; end
|
10
|
+
class CertificateError < Error; end
|
11
|
+
class EcKeyError < Error; end
|
12
|
+
|
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
|
+
}
|
38
|
+
|
39
|
+
def self.config
|
40
|
+
@@config ||= DEFAULTS
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.config=(other)
|
44
|
+
@@config = other
|
45
|
+
end
|
46
|
+
end
|
data/lib/pedicel/base.rb
ADDED
@@ -0,0 +1,197 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
module Pedicel
|
5
|
+
class Base
|
6
|
+
SUPPORTED_VERSIONS = [:EC_v1]
|
7
|
+
|
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'])
|
14
|
+
|
15
|
+
raise ReplayAttackError, "token signature time indicates a replay attack (age #{now-cms_signing_time})" unless signing_time_ok?(now: now)
|
16
|
+
|
17
|
+
raise SignatureError unless valid_signature?
|
18
|
+
end
|
19
|
+
|
20
|
+
def version
|
21
|
+
@token['version']&.to_sym
|
22
|
+
end
|
23
|
+
|
24
|
+
def encrypted_data
|
25
|
+
return nil unless @token['data']
|
26
|
+
|
27
|
+
Base64.decode64(@token['data'])
|
28
|
+
end
|
29
|
+
|
30
|
+
def signature
|
31
|
+
return nil unless @token['signature']
|
32
|
+
|
33
|
+
Base64.decode64(@token['signature'])
|
34
|
+
end
|
35
|
+
|
36
|
+
def transaction_id
|
37
|
+
[@token['header']['transactionId']].pack('H*')
|
38
|
+
end
|
39
|
+
|
40
|
+
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
|
51
|
+
|
52
|
+
delta = Pedicel.config[:replay_threshold_seconds]
|
53
|
+
|
54
|
+
cms_signing_time.between?(now - delta, now + delta)
|
55
|
+
# Deliberately ignoring leap seconds.
|
56
|
+
end
|
57
|
+
|
58
|
+
def private_key_class
|
59
|
+
{EC_v1: OpenSSL::PKey::EC, RSA_v1: OpenSSL::PKey::RSA}[version]
|
60
|
+
end
|
61
|
+
|
62
|
+
def symmetric_algorithm
|
63
|
+
{EC_v1: 'aes-256-gcm', RSA_v1: 'aes-128-gcm'}[version]
|
64
|
+
end
|
65
|
+
|
66
|
+
def decrypt_aes(key:)
|
67
|
+
raise TokenFormatError, 'no encrypted data present' unless encrypted_data
|
68
|
+
|
69
|
+
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.
|
72
|
+
|
73
|
+
cipher = OpenSSL::Cipher.new(symmetric_algorithm)
|
74
|
+
cipher.decrypt
|
75
|
+
|
76
|
+
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
|
80
|
+
|
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)
|
84
|
+
|
85
|
+
cipher.auth_tag = tag
|
86
|
+
cipher.auth_data = ''.b
|
87
|
+
|
88
|
+
cipher.update(untagged_encrypted_data) << cipher.final
|
89
|
+
else
|
90
|
+
require 'aes256gcm_decrypt'
|
91
|
+
|
92
|
+
Aes256GcmDecrypt::decrypt(encrypted_data, key)
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
def valid_signature?(now: Time.now)
|
97
|
+
true if validate_signature(now: now)
|
98
|
+
rescue
|
99
|
+
false
|
100
|
+
end
|
101
|
+
|
102
|
+
def verify_signature(ca_certificate_pem: Pedicel.config[:apple_root_ca_g3_cert_pem], now: Time.now)
|
103
|
+
raise SignatureError, 'no signature present' unless signature
|
104
|
+
|
105
|
+
begin
|
106
|
+
s = OpenSSL::PKCS7.new(signature)
|
107
|
+
rescue => e
|
108
|
+
raise SignatureError, "invalid PKCS #7 signature: #{e.message}"
|
109
|
+
end
|
110
|
+
|
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
|
+
begin
|
117
|
+
root = OpenSSL::X509::Certificate.new(ca_certificate_pem)
|
118
|
+
rescue => e
|
119
|
+
raise CertificateError, "invalid root certificate: #{e.message}"
|
120
|
+
end
|
121
|
+
|
122
|
+
# 1.b
|
123
|
+
# Ensure that the root CA is the Apple Root CA - G3. (...)
|
124
|
+
self.class.verify_root_certificate(root: root, intermediate: intermediate)
|
125
|
+
|
126
|
+
# 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)
|
129
|
+
|
130
|
+
# 1.d
|
131
|
+
# Validate the token's signature.
|
132
|
+
#
|
133
|
+
# Implemented in the subclass.
|
134
|
+
validate_signature(signature: s, leaf: leaf)
|
135
|
+
|
136
|
+
# 1.e
|
137
|
+
# Inspect the CMS signing time of the signature (...)
|
138
|
+
self.class.verify_signed_time(signature: s, now: now)
|
139
|
+
|
140
|
+
self
|
141
|
+
end
|
142
|
+
|
143
|
+
private
|
144
|
+
|
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
|
150
|
+
|
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]})"
|
154
|
+
end
|
155
|
+
|
156
|
+
[leaf, intermediate]
|
157
|
+
end
|
158
|
+
|
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
|
163
|
+
end
|
164
|
+
|
165
|
+
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)
|
170
|
+
|
171
|
+
raise SignatureError, 'invalid chain of trust' unless valid_chain
|
172
|
+
end
|
173
|
+
|
174
|
+
def self.verify_signed_time(signature:, now:)
|
175
|
+
# Inspect the CMS signing time of the signature, as defined by section
|
176
|
+
# 11.3 of RFC 5652. If the time signature and the transaction time differ
|
177
|
+
# by more than a few minutes, it's possible that the token is a replay
|
178
|
+
# attack.
|
179
|
+
|
180
|
+
unless signature.signers.length == 1
|
181
|
+
raise SignatureError, 'not 1 signer, unable to determine signing time'
|
182
|
+
end
|
183
|
+
signed_time = signature.signers.first.signed_time
|
184
|
+
|
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.
|
189
|
+
|
190
|
+
diff = signed_time - now
|
191
|
+
if diff.negative?
|
192
|
+
raise SignatureError, "signature too old; signed #{-diff.to_i}s ago"
|
193
|
+
end
|
194
|
+
raise SignatureError, "signature too new; signed #{diff.to_i}s in the future"
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
data/lib/pedicel/ec.rb
ADDED
@@ -0,0 +1,168 @@
|
|
1
|
+
require 'pedicel/base'
|
2
|
+
|
3
|
+
module Pedicel
|
4
|
+
class EC < Base
|
5
|
+
def ephemeral_public_key
|
6
|
+
Base64.decode64(@token['header']['ephemeralPublicKey'])
|
7
|
+
end
|
8
|
+
|
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)
|
26
|
+
end
|
27
|
+
|
28
|
+
verify_signature(ca_certificate_pem: ca_certificate_pem, now: now)
|
29
|
+
decrypt_aes(key: symmetric_key)
|
30
|
+
end
|
31
|
+
|
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
|
+
# '-----------------'
|
42
|
+
|
43
|
+
shared_secret = shared_secret(private_key: private_key) if private_key
|
44
|
+
merchant_id = self.class.merchant_id(certificate: certificate) if certificate
|
45
|
+
|
46
|
+
self.class.symmetric_key(shared_secret: shared_secret, merchant_id: merchant_id)
|
47
|
+
end
|
48
|
+
|
49
|
+
# Extract the shared secret from one public key (the ephemeral) and one
|
50
|
+
# private key.
|
51
|
+
def shared_secret(private_key:)
|
52
|
+
begin
|
53
|
+
privkey = OpenSSL::PKey::EC.new(private_key)
|
54
|
+
rescue => e
|
55
|
+
raise EcKeyError, "invalid PEM format of private key for EC: #{e.message}"
|
56
|
+
end
|
57
|
+
|
58
|
+
begin
|
59
|
+
pubkey = OpenSSL::PKey::EC.new(ephemeral_public_key).public_key
|
60
|
+
rescue => e
|
61
|
+
raise EcKeyError, "invalid format of ephemeralPublicKey (from token) for EC: #{e.message}"
|
62
|
+
end
|
63
|
+
|
64
|
+
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}'"
|
68
|
+
end
|
69
|
+
|
70
|
+
privkey.dh_compute_key(pubkey)
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.symmetric_key(merchant_id:, shared_secret:)
|
74
|
+
# http://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Ar2.pdf
|
75
|
+
# Section 5.8.1.1, The Single-Step KDF Specification.
|
76
|
+
#
|
77
|
+
# With slight adjustments:
|
78
|
+
# > 1. Set `reps = ceil(keydatalen/hashlen)`
|
79
|
+
# > 2. If `reps > (2^32 - 1)`, then return an error indicator without
|
80
|
+
# > performing the remaining actions.
|
81
|
+
# > 3. Initialize a 32-bit, big-endian bit string `counter` as 00000001 base
|
82
|
+
# > 16 (i.e. 0x00000001).
|
83
|
+
# > 4. If `counter || Z || OtherInfo` is more than `max_H_inputlen` bits
|
84
|
+
# > long, then return an error indicator without performing the remaining
|
85
|
+
# > actions.
|
86
|
+
# > 5. For `i = 1` to `reps` by `1`, do the following:
|
87
|
+
# > 5.1 Compute `K(i) = H(counter || Z || OtherInfo)`.
|
88
|
+
# > 5.2 Increment `counter` (modulo `2^32`), treating it as an
|
89
|
+
# > unsigned 32-bit integer.
|
90
|
+
# > 6. Let `K_Last` be set to `K(reps)` if `keydatalen / hashlen` is an
|
91
|
+
# > integer; otherwise, let `K_Last` be set to the `keydatalen mod
|
92
|
+
# > hashlen` leftmost bits of `K(reps)`.
|
93
|
+
# > 7. Return `K(1) || K(2) || ... || K(reps-1) || K_Last`.
|
94
|
+
#
|
95
|
+
# Digest::SHA256 will do the calculations when we throw Z and OtherInfo into
|
96
|
+
# the digest.
|
97
|
+
|
98
|
+
sha256 = Digest::SHA256.new
|
99
|
+
|
100
|
+
# Step 3:
|
101
|
+
sha256 << "\x00\x00\x00\x01"
|
102
|
+
|
103
|
+
# Z:
|
104
|
+
sha256 << shared_secret
|
105
|
+
|
106
|
+
# OtherInfo:
|
107
|
+
# https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
|
108
|
+
sha256 << "\x0d" + 'id-aes256-GCM' # AlgorithmID
|
109
|
+
sha256 << 'Apple' # PartyUInfo
|
110
|
+
sha256 << merchant_id # PartyVInfo
|
111
|
+
|
112
|
+
sha256.digest
|
113
|
+
end
|
114
|
+
|
115
|
+
def self.merchant_id(certificate:)
|
116
|
+
begin
|
117
|
+
cert = OpenSSL::X509::Certificate.new(certificate)
|
118
|
+
rescue => e
|
119
|
+
raise CertificateError, "invalid PEM format of certificate: #{e.message}"
|
120
|
+
end
|
121
|
+
|
122
|
+
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.
|
128
|
+
|
129
|
+
raise CertificateError, 'no merchant identifier in certificate' unless merchant_id_hex
|
130
|
+
|
131
|
+
[merchant_id_hex].pack('H*')
|
132
|
+
end
|
133
|
+
|
134
|
+
private
|
135
|
+
|
136
|
+
def validate_signature(signature:, leaf:)
|
137
|
+
# (...) ensure that the signature is a valid ECDSA signature
|
138
|
+
# (ecdsa-with-SHA256 1.2.840.10045.4.3.2) of the concatenated values of
|
139
|
+
# the ephemeralPublicKey, data, transactionId, and applicationData keys.
|
140
|
+
|
141
|
+
unless leaf.signature_algorithm == 'ecdsa-with-SHA256'
|
142
|
+
raise SignatureError, 'signature algorithm is not ecdsa-with-SHA256'
|
143
|
+
end
|
144
|
+
|
145
|
+
message = [
|
146
|
+
ephemeral_public_key,
|
147
|
+
encrypted_data,
|
148
|
+
transaction_id,
|
149
|
+
application_data,
|
150
|
+
].compact.join
|
151
|
+
|
152
|
+
# https://wiki.openssl.org/index.php/Manual:PKCS7_verify(3)#VERIFY_PROCESS
|
153
|
+
flags = \
|
154
|
+
OpenSSL::PKCS7::NOCHAIN | # Ignore certs in the message.
|
155
|
+
OpenSSL::PKCS7::NOINTERN | # Only look at the supplied certificate.
|
156
|
+
OpenSSL::PKCS7::NOVERIFY # Do not verify the chain; already done.
|
157
|
+
|
158
|
+
# Trust exactly the leaf which has already been verified.
|
159
|
+
certificates = [leaf]
|
160
|
+
|
161
|
+
store = OpenSSL::X509::Store.new
|
162
|
+
|
163
|
+
unless signature.verify(certificates, store, message, flags)
|
164
|
+
raise SignatureError, 'signature does not match the message'
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
data/lib/pedicel/rsa.rb
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
require 'dry-validation'
|
2
|
+
require 'base64'
|
3
|
+
|
4
|
+
module Pedicel
|
5
|
+
class Validator
|
6
|
+
class Error < StandardError; end
|
7
|
+
class TokenFormatError < Error; end
|
8
|
+
class TokenDataFormatError < Error; end
|
9
|
+
|
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
|
+
module Predicates
|
18
|
+
include Dry::Logic::Predicates
|
19
|
+
|
20
|
+
predicate(:base64?) { |value| !Base64.decode64(value).nil? rescue false }
|
21
|
+
|
22
|
+
predicate(:hex?) { |value| !Regexp.new(/\A[a-f0-9-]*\z/i).match(value).nil? }
|
23
|
+
|
24
|
+
predicate(:pan?) { |value| !Regexp.new(/\A[0-9]{13,19}\z/).match(value).nil? }
|
25
|
+
|
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
|
30
|
+
end
|
31
|
+
|
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
|
38
|
+
end
|
39
|
+
|
40
|
+
required(:data).filled(:str?, :base64?)
|
41
|
+
|
42
|
+
required(:header).schema do
|
43
|
+
optional(:applicationData).filled(:str?, :hex?)
|
44
|
+
|
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
|
50
|
+
|
51
|
+
required(:publicKeyHash).filled(:str?, :base64?)
|
52
|
+
|
53
|
+
required(:transactionId).filled(:str?, :hex?)
|
54
|
+
end
|
55
|
+
|
56
|
+
required(:signature).filled(:str?, :base64?)
|
57
|
+
|
58
|
+
required(:version).filled(:str?, included_in?: ['EC_v1', 'RSA_v1'])
|
59
|
+
end
|
60
|
+
|
61
|
+
# Pedicel::Validator::TokenSchema.call({data: 'asdf', header: {ephemeralPublicKey: 'e', publicKeyHash: 'p', transactionId: 'f'}, signature: 's', version: 'EC_v1'})
|
62
|
+
|
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
|
70
|
+
|
71
|
+
required(:applicationPrimaryAccountNumber).filled(:str?, :pan?)
|
72
|
+
|
73
|
+
required(:applicationExpirationDate).filled(:str?, :yymmdd?)
|
74
|
+
|
75
|
+
required(:currencyCode).filled(:str?, format?: /\A[0-9]{3}\z/)
|
76
|
+
|
77
|
+
required(:transactionAmount).filled(:int?)
|
78
|
+
|
79
|
+
optional(:cardholderName).filled(:str?)
|
80
|
+
|
81
|
+
required(:deviceManufacturerIdentifier).filled(:str?, :hex?)
|
82
|
+
|
83
|
+
required(:paymentDataType).filled(:str?, included_in?: ['3DSecure', 'EMV'])
|
84
|
+
|
85
|
+
required(:paymentData).schema do
|
86
|
+
optional(:onlinePaymentCryptogram).filled(:str?, :base64?)
|
87
|
+
optional(:eciIndicator).filled(:str?)
|
88
|
+
|
89
|
+
optional(:emvData).filled(:str?, :base64?)
|
90
|
+
optional(:encryptedPINData).filled(:str?, :hex?)
|
91
|
+
end
|
92
|
+
|
93
|
+
rule('consistent paymentDataType and paymentData': [:paymentDataType, [:paymentData, :onlinePaymentCryptogram]]) do |t, cryptogram|
|
94
|
+
t.eql?('3DSecure') > cryptogram.filled?
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Pedicel::Validator::TokenDataSchema.call(
|
99
|
+
# applicationPrimaryAccountNumber: '1234567890123',
|
100
|
+
# applicationExpirationDate: '101112',
|
101
|
+
# currencyCode: '123',
|
102
|
+
# transactionAmount: 12.34,
|
103
|
+
# cardholderName: 'asdf',
|
104
|
+
# deviceManufacturerIdentifier: 'adsf',
|
105
|
+
# paymentDataType: 'asdf',
|
106
|
+
# )
|
107
|
+
|
108
|
+
def self.validate_token(token, now: Time.now)
|
109
|
+
validation = TokenSchema.call(token)
|
110
|
+
|
111
|
+
raise TokenFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
|
112
|
+
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
def self.valid_token?(token, now: Time.now)
|
117
|
+
validate_token(token, now: now) rescue false
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.validate_token_data(token_data)
|
121
|
+
validation = TokenDataSchema.call(token_data)
|
122
|
+
|
123
|
+
raise TokenDataFormatError, validation.hints.map{|key,msg| "#{key} #{msg}"}.join(', and ') unless validation.errors.empty?
|
124
|
+
|
125
|
+
true
|
126
|
+
end
|
127
|
+
|
128
|
+
def self.valid_token_data?(token_data)
|
129
|
+
validate_token_data(token_data) rescue false
|
130
|
+
end
|
131
|
+
|
132
|
+
def validate_content(now: Time.now)
|
133
|
+
raise ReplayAttackError, "token signature time indicates a replay attack (age #{now-cms_signing_time})" unless signing_time_ok?(now: now)
|
134
|
+
|
135
|
+
raise SignatureError unless valid_signature?
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
metadata
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: pedicel
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Clearhaus
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2018-02-27 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: dry-validation
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 0.11.1
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
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: 0.0.2
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.0.2
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.7'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.7'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: pry
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0.11'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0.11'
|
69
|
+
description:
|
70
|
+
email: hello@clearhaus.com
|
71
|
+
executables: []
|
72
|
+
extensions: []
|
73
|
+
extra_rdoc_files: []
|
74
|
+
files:
|
75
|
+
- lib/pedicel.rb
|
76
|
+
- lib/pedicel/base.rb
|
77
|
+
- lib/pedicel/ec.rb
|
78
|
+
- lib/pedicel/rsa.rb
|
79
|
+
- lib/pedicel/validator.rb
|
80
|
+
homepage: https://github.com/clearhaus/pedicel-pay
|
81
|
+
licenses:
|
82
|
+
- MIT
|
83
|
+
metadata: {}
|
84
|
+
post_install_message:
|
85
|
+
rdoc_options: []
|
86
|
+
require_paths:
|
87
|
+
- lib
|
88
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
89
|
+
requirements:
|
90
|
+
- - ">="
|
91
|
+
- !ruby/object:Gem::Version
|
92
|
+
version: '0'
|
93
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
94
|
+
requirements:
|
95
|
+
- - ">="
|
96
|
+
- !ruby/object:Gem::Version
|
97
|
+
version: '0'
|
98
|
+
requirements: []
|
99
|
+
rubyforge_project:
|
100
|
+
rubygems_version: 2.5.2
|
101
|
+
signing_key:
|
102
|
+
specification_version: 4
|
103
|
+
summary: Backend and client part of Apple Pay
|
104
|
+
test_files: []
|