pedicel 0.0.1
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 +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: []
|