pedicel-pay 0.0.1 → 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bdb70920d44fc3ca94c23bf72503189c8a382675aab89f1f3247a59b05ef353f
4
- data.tar.gz: a014ce297bc24449810a25cdae9fd29fc39344a6bf4cbf5180aa55b1d1f40a91
3
+ metadata.gz: 68626ff88a9448225853d1c8547535236f4a734fe850618a3cf5ee7593c4ccee
4
+ data.tar.gz: 64cbedfe02190675426b05e0db34f386be38c38c46a4d00029bd22578630f687
5
5
  SHA512:
6
- metadata.gz: e6ca39b5d3b99fe056eb145f62b01b976924ef2e1de997671925f0975388319965d2a5a34c5eede178c83f4fee84564ff27b823db3caa2bad129867f3a4f265b
7
- data.tar.gz: 966fdf5fbab66e081952e3014498fd689f28b165b5a522ba3c642920838115178954d1e2954de4adcffe60ecaa46cf5de32942c60f3832c9b79736a300ed9193
6
+ metadata.gz: 070601a57849d8d89aff29301cf9b299beba45f6f0ba6d2922f4be7c969c4d69ad0cf0e108bc0946116f078fad7fb349dd712ed063da856bc854d2da8f3f54ef
7
+ data.tar.gz: 92b6937df63bd603bd8abf06a261b57af47aa9898a4a8cfbf179079f8e74f4bf4a9b658028a58561098b07704b75e0223ef7668f83424ade10d245bcac24bf86
data/exe/pedicel-pay ADDED
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'bundler/setup'
4
+ require 'thor'
5
+ require 'pedicel-pay'
6
+ require 'json'
7
+ require 'time'
8
+
9
+ module PedicelPay
10
+ class Cli < Thor
11
+ BACKEND_FILES = [
12
+ 'ca-key.pem',
13
+ 'ca-certificate.pem',
14
+ 'intermediate-key.pem',
15
+ 'intermediate-certificate.pem',
16
+ 'leaf-key.pem',
17
+ 'leaf-certificate.pem',
18
+ ]
19
+
20
+ CLIENT_FILES = [
21
+ 'client-key.pem',
22
+ 'client-certificate.pem',
23
+ ]
24
+
25
+ FILES = BACKEND_FILES | CLIENT_FILES
26
+
27
+
28
+ desc 'clean', 'Remove all generated files'
29
+ option :path, type: :string, path: true, aliases: :p
30
+
31
+ def clean
32
+ FILES.
33
+ map { |f| options[:path] ? File.join(File.expand_path(options[:path]), f) : f }.
34
+ each { |f| File.delete(f) if File.exist?(f) }
35
+ end
36
+
37
+
38
+ desc 'generate-backend', 'Generate a backend: CA, intermediate and leaf certificates and keys'
39
+ option 'force', type: :boolean, aliases: :f
40
+ option 'destination', type: :string, aliases: [:dest, :d]
41
+ option 'path', type: :string, path: true, aliases: :p
42
+ option 'valid-from', type: :string
43
+ option 'valid-to', type: :string
44
+
45
+ def generate_backend
46
+ path = File.expand_path(options[:path] || '.')
47
+
48
+ BACKEND_FILES.map{|f| File.join(path, f)}.each do |file|
49
+ if File.exist?(file)
50
+ $stderr.puts "File #{file} already exist"
51
+ exit 1
52
+ end
53
+ end unless options[:force]
54
+
55
+ Dir.mkdir(path) unless File.directory?(path)
56
+
57
+ PedicelPay.config[:valid] = \
58
+ (options['valid-from'] ? Time.parse(options['valid-from']) : PedicelPay.config[:valid].min) ..
59
+ (options['valid-to'] ? Time.parse(options['valid-to']) : PedicelPay.config[:valid].max)
60
+
61
+ backend = PedicelPay::Backend.generate
62
+
63
+ File.open(File.join(path, 'ca-key.pem'), 'w') { |f| f.write(backend.ca_key.to_pem) }
64
+ File.open(File.join(path, 'ca-certificate.pem'), 'w') { |f| f.write(backend.ca_certificate.to_pem) }
65
+ File.open(File.join(path, 'intermediate-key.pem'), 'w') { |f| f.write(backend.intermediate_key.to_pem) }
66
+ File.open(File.join(path, 'intermediate-certificate.pem'), 'w') { |f| f.write(backend.intermediate_certificate.to_pem) }
67
+ File.open(File.join(path, 'leaf-key.pem'), 'w') { |f| f.write(backend.leaf_key.to_pem) }
68
+ File.open(File.join(path, 'leaf-certificate.pem'), 'w') { |f| f.write(backend.leaf_certificate.to_pem) }
69
+ end
70
+
71
+
72
+ desc 'check-backend', 'Check that the backend created is functional'
73
+ option :path, type: :string, path: true, aliases: :p
74
+
75
+ def check_backend
76
+ backend = Helper.load_backend(options[:path])
77
+
78
+ backend.is_a?(PedicelPay::Backend) && backend.validate
79
+ rescue => e
80
+ $stderr.puts e
81
+ exit 1
82
+ end
83
+
84
+
85
+ desc 'generate-client', 'Generate a client, the merchant side'
86
+ option 'backend-path', type: :string, path: true, aliases: :b
87
+ option 'force', type: :boolean, aliases: :f
88
+ option 'path', type: :string, path: true, aliases: :p
89
+ option 'valid-from', type: :string
90
+ option 'valid-to', type: :string
91
+
92
+ def generate_client
93
+ path = File.expand_path(options[:path] || '.')
94
+
95
+ CLIENT_FILES.map{|f| File.join(path, f)}.each do |file|
96
+ if File.exist?(file)
97
+ $stderr.puts "File #{file} already exist"
98
+ exit 1
99
+ end
100
+ end unless options[:force]
101
+
102
+ Dir.mkdir(path) unless File.directory?(path)
103
+
104
+ valid = \
105
+ (options['valid-from'] ? Time.parse(options['valid-from']) : PedicelPay.config[:valid].min) ..
106
+ (options['valid-to'] ? Time.parse(options['valid-to']) : PedicelPay.config[:valid].max)
107
+
108
+ client = Helper.load_backend(options['backend-path']).generate_client(valid: valid)
109
+
110
+ File.open(File.join(path, 'client-key.pem'), 'w') { |f| f.write(client.key.to_pem) }
111
+ File.open(File.join(path, 'client-certificate.pem'), 'w') { |f| f.write(client.certificate.to_pem) }
112
+ end
113
+
114
+
115
+ desc 'generate-token', 'Generate a token'
116
+ option 'backend-path', type: :string, path: true, aliases: :b
117
+ option 'client-path', type: :string, path: true, aliases: :c
118
+ option 'amount', type: :string
119
+ option 'currency', type: :string
120
+ option 'pan', type: :string
121
+ option 'expiry', type: :string
122
+
123
+ def generate_token
124
+ backend = Helper.load_backend(options['backend-path'])
125
+ client = Helper.load_client(options['client-path'])
126
+
127
+ token = PedicelPay::Token.new
128
+ token.sample
129
+
130
+ token.unencrypted_data.amount = options['amount'].to_i if options['amount']&.to_i
131
+ token.unencrypted_data.currency = options['currency'] if options['currency']
132
+ token.unencrypted_data.pan = options['pan'] if options['pan']
133
+ token.unencrypted_data.expiry = options['expiry'] if options['expiry']
134
+
135
+ backend.encrypt_and_sign(token, recipient: client)
136
+
137
+ puts token.to_json
138
+ end
139
+
140
+
141
+ desc 'decrypt-token', 'Decrypt a token'
142
+ option 'client-path', type: :string, path: true, aliases: :c
143
+ option 'backend-path', type: :string, path: true, aliases: :b
144
+ option 'file', type: :string, aliases: :f
145
+
146
+ def decrypt_token
147
+ raw_token = options['file'] ? File.read(options['file']) : $stdin.read
148
+ token = JSON.parse(raw_token)
149
+
150
+ client = Helper.load_client(options['client-path'])
151
+ backend = Helper.load_backend(options['backend-path'])
152
+
153
+ puts client.decrypt(token, ca_certificate_pem: backend.ca_certificate.to_pem)
154
+ end
155
+ end
156
+
157
+ class Helper
158
+ def self.load_backend(path)
159
+ path = File.expand_path(path || '.')
160
+
161
+ PedicelPay::Backend.new(
162
+ ca_key: OpenSSL::PKey::EC.new( File.read(File.join(path, 'ca-key.pem'))),
163
+ ca_certificate: OpenSSL::X509::Certificate.new(File.read(File.join(path, 'ca-certificate.pem'))),
164
+ intermediate_key: OpenSSL::PKey::EC.new( File.read(File.join(path, 'intermediate-key.pem'))),
165
+ intermediate_certificate: OpenSSL::X509::Certificate.new(File.read(File.join(path, 'intermediate-certificate.pem'))),
166
+ leaf_key: OpenSSL::PKey::EC.new( File.read(File.join(path, 'leaf-key.pem'))),
167
+ leaf_certificate: OpenSSL::X509::Certificate.new(File.read(File.join(path, 'leaf-certificate.pem')))
168
+ )
169
+ end
170
+
171
+ def self.load_client(path)
172
+ path = File.expand_path(path || '.')
173
+
174
+ PedicelPay::Client.new(
175
+ key: OpenSSL::PKey::EC.new( File.read(File.join(path, 'client-key.pem'))),
176
+ certificate: OpenSSL::X509::Certificate.new(File.read(File.join(path, 'client-certificate.pem'))),
177
+ )
178
+ end
179
+ end
180
+ end
181
+
182
+ PedicelPay::Cli.start(ARGV)
@@ -0,0 +1,261 @@
1
+ require 'pedicel-pay/helper'
2
+ require 'pedicel'
3
+ require 'openssl'
4
+
5
+ module PedicelPay
6
+ class Backend
7
+ Error = Class.new(PedicelPay::Error)
8
+ CertificateError = Class.new(PedicelPay::Backend::Error)
9
+ KeyError = Class.new(PedicelPay::Backend::Error)
10
+
11
+ attr_accessor \
12
+ :ca_key, :ca_certificate,
13
+ :intermediate_key, :intermediate_certificate,
14
+ :leaf_key, :leaf_certificate
15
+
16
+ def initialize(ca_key: nil, ca_certificate: nil,
17
+ intermediate_key: nil, intermediate_certificate: nil,
18
+ leaf_key: nil, leaf_certificate: nil)
19
+ @ca_key = ca_key
20
+ @ca_certificate = ca_certificate
21
+
22
+ @intermediate_key = intermediate_key
23
+ @intermediate_certificate = intermediate_certificate
24
+
25
+ @leaf_key = leaf_key
26
+ @leaf_certificate = leaf_certificate
27
+ end
28
+
29
+ def generate_client(valid: PedicelPay.config[:valid])
30
+ client = PedicelPay::Client.new(ca_certificate_pem: ca_certificate.to_pem)
31
+ client.generate_key
32
+
33
+ client.ca_certificate_pem = ca_certificate.to_pem
34
+ client.certificate = sign_csr(client.generate_csr, valid: valid)
35
+
36
+ client
37
+ end
38
+
39
+ def sign_csr(csr, valid: PedicelPay.config[:valid])
40
+ cert = OpenSSL::X509::Certificate.new
41
+ cert.version = 2
42
+ cert.serial = 1
43
+ cert.not_before = valid.min
44
+ cert.not_after = valid.max
45
+ cert.subject = PedicelPay.config[:subject][:client]
46
+ cert.public_key = csr.public_key
47
+ cert.issuer = intermediate_certificate.issuer
48
+ cert.sign(intermediate_key, OpenSSL::Digest::SHA256.new)
49
+
50
+ merchant_id_hex = Helper.bytestring_to_hex(PedicelPay.config[:random].bytes(32))
51
+
52
+ cert.add_extension(OpenSSL::X509::Extension.new(PedicelPay.config[:oid][:merchant_identifier_field], merchant_id_hex))
53
+
54
+ cert
55
+ end
56
+
57
+ def encrypt_and_sign(token, recipient:, shared_secret: nil, ephemeral_pubkey: nil)
58
+ encrypt(token, recipient: recipient, shared_secret: shared_secret, ephemeral_pubkey: ephemeral_pubkey)
59
+ sign(token)
60
+
61
+ token
62
+ end
63
+
64
+ def encrypt(token, recipient:, shared_secret: nil, ephemeral_pubkey: nil)
65
+ raise ArgumentError, 'invalid token' unless token.is_a?(Token)
66
+
67
+ if shared_secret && ephemeral_pubkey
68
+ # Use them. No check that they come from the same ephemeral secret key.
69
+ elsif shared_secret.nil? ^ ephemeral_pubkey.nil?
70
+ raise ArgumentError, "'shared_secret' and 'ephemeral_pubkey' must be supplied together"
71
+ else # None of shared_secret or ephemeral_pubkey is supplied.
72
+ shared_secret, ephemeral_pubkey = self.class.generate_shared_secret_and_ephemeral_pubkey(recipient: recipient)
73
+ end
74
+
75
+ symmetric_key = Pedicel::EC.symmetric_key(shared_secret: shared_secret, merchant_id: Helper.merchant_id(recipient))
76
+
77
+ token.encrypted_data = Helper.encrypt(
78
+ data: token.unencrypted_data.to_json,
79
+ key: symmetric_key
80
+ )
81
+
82
+ token.header.ephemeral_pubkey = ephemeral_pubkey
83
+ token.update_pubkey_hash(recipient: recipient)
84
+
85
+ token
86
+ end
87
+
88
+ def sign(token, certificate: leaf_certificate, key: leaf_key)
89
+ raise ArgumentError, 'token has no encrypted_data' unless token.encrypted_data
90
+ raise ArgumentError, 'token has no ephemeral_pubkey' unless token.header.ephemeral_pubkey
91
+
92
+ message = [
93
+ Helper.ec_key_to_pkey_public_key(token.header.ephemeral_pubkey).to_der,
94
+ token.encrypted_data,
95
+ token.header.transaction_id,
96
+ token.header.data_hash
97
+ ].compact.join
98
+
99
+ signature = OpenSSL::PKCS7.sign(
100
+ certificate,
101
+ key,
102
+ message,
103
+ [intermediate_certificate, ca_certificate], # Chain.
104
+ OpenSSL::PKCS7::BINARY # Handle 0x00 correctly.
105
+ )
106
+
107
+ if token.signature # Already signed.
108
+ oldsig = OpenSSL::PKCS7.new(Base64.strict_decode64(token.signature))
109
+ signature = oldsig.add_signer(signature.signers.first)
110
+ end
111
+
112
+ token.signature = Base64.strict_encode64(signature.to_der)
113
+
114
+ token
115
+ end
116
+
117
+ def self.generate_shared_secret_and_ephemeral_pubkey(recipient:)
118
+ pubkey = case recipient
119
+ when Client
120
+ OpenSSL::PKey::EC.new(recipient.certificate.public_key).public_key
121
+ when OpenSSL::X509::Certificate
122
+ OpenSSL::PKey::EC.new(recipient.public_key).public_key
123
+ when OpenSSL::PKey::EC::Point
124
+ recipient
125
+ else raise ArgumentError, 'invalid recipient'
126
+ end
127
+
128
+ ephemeral_seckey = OpenSSL::PKey::EC.new(PedicelPay::EC_CURVE).generate_key
129
+
130
+ [ephemeral_seckey.dh_compute_key(pubkey), ephemeral_seckey.public_key]
131
+ end
132
+
133
+ def self.generate(config: PedicelPay.config)
134
+ ck, cc = generate_ca(config: config)
135
+
136
+ ik, ic = generate_intermediate(ca_key: ck, ca_certificate: cc, config: config)
137
+
138
+ lk, lc = generate_leaf(intermediate_key: ik, intermediate_certificate: ic, config: config)
139
+
140
+ new(ca_key: ck, ca_certificate: cc,
141
+ intermediate_key: ik, intermediate_certificate: ic,
142
+ leaf_key: lk, leaf_certificate: lc)
143
+ end
144
+
145
+ def self.generate_ca(config: PedicelPay.config)
146
+ key = OpenSSL::PKey::EC.new(PedicelPay::EC_CURVE)
147
+ key.generate_key
148
+
149
+ cert = OpenSSL::X509::Certificate.new
150
+ cert.version = 2 # https://www.ietf.org/rfc/rfc5280.txt -> Section 4.1, search for "v3(2)".
151
+ cert.serial = 1
152
+ cert.subject = config[:subject][:ca]
153
+ cert.issuer = cert.subject # Self-signed
154
+ cert.public_key = PedicelPay::Helper.ec_key_to_pkey_public_key(key)
155
+ cert.not_before = config[:valid].min
156
+ cert.not_after = config[:valid].max
157
+
158
+ ef = OpenSSL::X509::ExtensionFactory.new
159
+ ef.subject_certificate = cert
160
+ ef.issuer_certificate = cert
161
+ cert.add_extension(ef.create_extension('basicConstraints','CA:TRUE',true))
162
+ cert.add_extension(ef.create_extension('keyUsage','keyCertSign, cRLSign', true))
163
+ cert.add_extension(ef.create_extension('subjectKeyIdentifier','hash',false))
164
+ cert.add_extension(ef.create_extension('authorityKeyIdentifier','keyid:always',false))
165
+ cert.sign(key, OpenSSL::Digest::SHA256.new)
166
+
167
+ [key, cert]
168
+ end
169
+
170
+ def self.generate_intermediate(ca_key:, ca_certificate:, config: PedicelPay.config)
171
+ key = OpenSSL::PKey::EC.new(PedicelPay::EC_CURVE)
172
+ key.generate_key
173
+
174
+ cert = OpenSSL::X509::Certificate.new
175
+ # https://www.ietf.org/rfc/rfc5280.txt -> Section 4.1, search for "v3(2)".
176
+ cert.version = 2
177
+ cert.serial = 1
178
+ cert.subject = config[:subject][:intermediate]
179
+ cert.issuer = ca_certificate.subject
180
+ cert.public_key = PedicelPay::Helper.ec_key_to_pkey_public_key(key)
181
+ cert.not_before = config[:valid].min
182
+ cert.not_after = config[:valid].max
183
+
184
+ ef = OpenSSL::X509::ExtensionFactory.new
185
+ ef.subject_certificate = cert
186
+ ef.issuer_certificate = ca_certificate
187
+
188
+ # According to https://tools.ietf.org/html/rfc5280#section-4.2.1.9,
189
+ # CA:TRUE must be set in order to allow signing using this intermediate
190
+ # certificate.
191
+ cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true))
192
+
193
+ cert.add_extension(ef.create_extension('keyUsage', 'keyCertSign, cRLSign', true))
194
+ cert.add_extension(ef.create_extension('subjectKeyIdentifier', 'hash', false))
195
+
196
+ cert.add_extension(OpenSSL::X509::Extension.new(config[:oid][:intermediate_certificate], ''))
197
+
198
+ cert.sign(ca_key, OpenSSL::Digest::SHA256.new)
199
+
200
+ [key, cert]
201
+ end
202
+
203
+ def self.generate_leaf(intermediate_key:, intermediate_certificate:, config: PedicelPay.config)
204
+ key = OpenSSL::PKey::EC.new(PedicelPay::EC_CURVE)
205
+ key.generate_key
206
+
207
+ cert = OpenSSL::X509::Certificate.new
208
+ cert.version = 2 # https://www.ietf.org/rfc/rfc5280.txt -> Section 4.1, search for "v3(2)".
209
+ cert.serial = 1
210
+ cert.subject = config[:subject][:leaf]
211
+ cert.issuer = intermediate_certificate.subject
212
+ cert.public_key = PedicelPay::Helper.ec_key_to_pkey_public_key(key)
213
+ cert.not_before = config[:valid].min
214
+ cert.not_after = config[:valid].max
215
+
216
+ ef = OpenSSL::X509::ExtensionFactory.new
217
+ ef.subject_certificate = cert
218
+ ef.issuer_certificate = intermediate_certificate
219
+ cert.add_extension(ef.create_extension('keyUsage','digitalSignature', true))
220
+ cert.add_extension(ef.create_extension('subjectKeyIdentifier','hash',false))
221
+
222
+ cert.add_extension(OpenSSL::X509::Extension.new(config[:oid][:leaf_certificate], ''))
223
+
224
+ cert.sign(intermediate_key, OpenSSL::Digest::SHA256.new)
225
+
226
+ [key, cert]
227
+ end
228
+
229
+ def validate
230
+ validate_ca
231
+ validate_intermediate
232
+ validate_leaf
233
+
234
+ true
235
+ end
236
+
237
+ def validate_ca
238
+ raise KeyError, 'ca private key not valid for ca certificate' unless ca_certificate.check_private_key(ca_key)
239
+
240
+ raise CertificateError, 'ca certificate is not self-signed' unless ca_certificate.verify(ca_key)
241
+
242
+ true
243
+ end
244
+
245
+ def validate_intermediate
246
+ raise KeyError, 'intermediate private key not valid for intermediate certificate' unless intermediate_certificate.check_private_key(intermediate_key)
247
+
248
+ raise CertificateError, 'intermediate certificate not signed by ca' unless intermediate_certificate.verify(ca_key)
249
+
250
+ true
251
+ end
252
+
253
+ def validate_leaf
254
+ raise KeyError, 'leaf private key not valid for leaf certificate' unless leaf_certificate.check_private_key(leaf_key)
255
+
256
+ raise CertificateError, 'leaf certificate not signed by intermediate' unless leaf_certificate.verify(intermediate_key)
257
+
258
+ true
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,42 @@
1
+ require 'pedicel-pay/helper'
2
+ require 'openssl'
3
+ require 'pedicel'
4
+
5
+ module PedicelPay
6
+ class Client
7
+ attr_accessor :key, :certificate, :ca_certificate_pem
8
+
9
+ def initialize(key: nil, certificate: nil, ca_certificate_pem: nil)
10
+ @key = key
11
+ @certificate = certificate
12
+ @ca_certificate_pem = ca_certificate_pem
13
+ end
14
+
15
+ def generate_key
16
+ @key = OpenSSL::PKey::EC.new(PedicelPay::EC_CURVE)
17
+ @key.generate_key
18
+
19
+ @key
20
+ end
21
+
22
+ def generate_csr(subject: PedicelPay.config[:subject][:csr])
23
+ req = OpenSSL::X509::Request.new
24
+ req.version = 0
25
+ req.subject = subject
26
+ req.public_key = PedicelPay::Helper.ec_key_to_pkey_public_key(key)
27
+ req.sign(key, OpenSSL::Digest::SHA256.new)
28
+
29
+ req
30
+ end
31
+
32
+ def merchant_id
33
+ Pedicel::EC.merchant_id(certificate: certificate)
34
+ end
35
+
36
+ def decrypt(token, ca_certificate_pem: @ca_certificate_pem, now: Time.now)
37
+ Pedicel::EC.
38
+ new(token).
39
+ decrypt(private_key: key, certificate: certificate, ca_certificate_pem: ca_certificate_pem, now: now)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PedicelPay
4
+ # Assistance/collection functions.
5
+ class Helper
6
+ def initialize(config: PedicelPay.config, pedicel_instance: nil)
7
+ @config = config
8
+ @pedicel = pedicel_instance
9
+ end
10
+
11
+ def self.ec_key_to_pkey_public_key(ec_key)
12
+ # EC#public_key is not a PKey public key, but an EC point.
13
+ pub = OpenSSL::PKey::EC.new(ec_key.group)
14
+ pub.public_key = ec_key.is_a?(OpenSSL::PKey::PKey) ? ec_key.public_key : ec_key
15
+
16
+ pub
17
+ end
18
+
19
+ def self.bytestring_to_hex(string)
20
+ string.unpack('H*').first
21
+ end
22
+
23
+ def self.merchant_id(x)
24
+ case x
25
+ when Client
26
+ Pedicel::EC.merchant_id(certificate: x.certificate)
27
+ when OpenSSL::X509::Certificate
28
+ Pedicel::EC.merchant_id(certificate: x)
29
+ when /\A[0-9a-fA-F]{64}\z/
30
+ [x].pack('H*')
31
+ when /\A.{32}\z/
32
+ x
33
+ else
34
+ raise ArgumentError, "cannot extract 'merchant_id' from #{x}"
35
+ end
36
+ end
37
+
38
+ def self.recipient_certificate(recipient:)
39
+ case recipient
40
+ when Client then recipient.certificate
41
+ when OpenSSL::X509::Certificate then recipient
42
+ else raise ArgumentError, 'invalid recipient'
43
+ end
44
+ end
45
+
46
+ def self.encrypt(data:, key:)
47
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
48
+ cipher.encrypt
49
+ cipher.key = key
50
+ cipher.iv_len = 16
51
+ cipher.iv = 0.chr * cipher.iv_len
52
+ cipher.auth_data = ''
53
+ cipher.update(data) + cipher.final + cipher.auth_tag
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,74 @@
1
+ require 'pedicel-pay/token_data'
2
+ require 'pedicel-pay/token_header'
3
+ require 'digest'
4
+
5
+ module PedicelPay
6
+ class Token
7
+ Error = Class.new(PedicelPay::Error)
8
+
9
+ attr_accessor \
10
+ :header,
11
+ :signature,
12
+ :unencrypted_data,
13
+ :encrypted_data,
14
+ :version
15
+
16
+ def initialize(unencrypted_data: nil, encrypted_data: nil, header: nil, signature: nil, version: 'EC_v1')
17
+ @unencrypted_data = unencrypted_data
18
+ @encrypted_data = encrypted_data
19
+ @header = header
20
+ @signature = signature
21
+ @version = version
22
+ end
23
+
24
+ def update_pubkey_hash(recipient:)
25
+ pubkey = Helper.recipient_certificate(recipient: recipient)
26
+
27
+ header.pubkey_hash = Digest::SHA256.base64digest(pubkey.to_der)
28
+ end
29
+
30
+ def to_json
31
+ to_hash.to_json
32
+ end
33
+
34
+ def to_hash
35
+ raise Error, 'no encrypted data' unless encrypted_data
36
+
37
+ {
38
+ 'data' => Base64.strict_encode64(encrypted_data),
39
+ 'header' => header.to_hash,
40
+ 'signature' => signature,
41
+ 'version' => version,
42
+ }
43
+ end
44
+
45
+ def sample
46
+ sample_data
47
+ sample_header
48
+
49
+ self
50
+ end
51
+
52
+ def sample_data
53
+ return if encrypted_data
54
+
55
+ if unencrypted_data
56
+ unencrypted_data.sample
57
+ else
58
+ self.unencrypted_data = TokenData.new.sample
59
+ end
60
+
61
+ self
62
+ end
63
+
64
+ def sample_header
65
+ if header
66
+ header.sample
67
+ else
68
+ self.header = TokenHeader.new.sample
69
+ end
70
+
71
+ self
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,152 @@
1
+ require 'json'
2
+
3
+ module PedicelPay
4
+ class TokenData
5
+ Error = Class.new(PedicelPay::Error)
6
+ DateError = Class.new(Error)
7
+
8
+ attr_accessor \
9
+ :pan,
10
+ :expiry,
11
+ :currency,
12
+ :amount,
13
+ :name,
14
+ :dm_id,
15
+ :cryptogram,
16
+ :eci
17
+
18
+ CURRENCIES = %w[
19
+ 008 012 032 036 044 048 050 051 052 060 064 068 072 084 090 096 104 108
20
+ 116 124 132 136 144 152 156 170 174 188 191 192 203 208 214 222 230 232
21
+ 238 242 262 270 292 320 324 328 332 340 344 348 352 356 360 364 368 376
22
+ 388 392 398 400 404 408 410 414 417 418 422 426 430 434 446 454 458 462
23
+ 480 484 496 498 504 512 516 524 532 533 548 554 558 566 578 586 590 598
24
+ 600 604 608 634 643 646 654 682 690 694 702 704 706 710 728 748 752 756
25
+ 760 764 776 780 784 788 800 807 818 826 834 840 858 860 882 886 901 929
26
+ 930 931 932 933 934 936 937 938 940 941 943 944 946 947 948 949 950 951
27
+ 952 953 955 956 957 958 959 960 961 962 963 964 965 967 968 969 970 971
28
+ 972 973 975 976 977 978 979 980 981 984 985 986 990 994 997 999
29
+ ].freeze
30
+
31
+ def initialize(pan: nil, expiry: nil, currency: nil, amount: nil,
32
+ name: nil, dm_id: nil, cryptogram: nil, eci: nil)
33
+ @pan = pan
34
+ @expiry = expiry
35
+ @currency = currency
36
+ @amount = amount
37
+ @name = name
38
+ @dm_id = dm_id
39
+ @cryptogram = cryptogram
40
+ @eci = eci
41
+ end
42
+
43
+ def to_hash
44
+ data = { onlinePaymentCryptogram: cryptogram }
45
+ data[:eciIndicator] = eci if eci
46
+
47
+ result = {
48
+ applicationPrimaryAccountNumber: pan,
49
+ applicationExpirationDate: expiry,
50
+ currencyCode: currency,
51
+ transactionAmount: amount,
52
+ deviceManufacturerIdentifier: dm_id,
53
+ paymentDataType: '3DSecure',
54
+ paymentData: data
55
+ }
56
+
57
+ result[:cardholderName] = name if name
58
+
59
+ result
60
+ end
61
+
62
+ def to_json
63
+ to_hash.to_json
64
+ end
65
+
66
+ def sample(expired: nil, pan_length: nil)
67
+ # PAN
68
+ # Override @pan if pan_length doesn't match.
69
+ if pan.nil? || (pan_length && pan.length != pan_length)
70
+ pan_length ||= [12, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 19, 19, 19].sample
71
+
72
+ self.pan = [ [2, 4, 5, 6].sample, *(2..pan_length).map { rand(0..9) } ].join
73
+ end
74
+
75
+ # Expiry
76
+ # Override @expiry if it doesn't match `expired`.
77
+ # WARNING: Time calculations ahead!
78
+ # Think very carefully about all the crazy corner cases.
79
+ now = Time.now
80
+ if expiry.nil? || (expired ^ card_expired?(now)) # Cannot use "soon".
81
+ self.expiry = self.class.sample_expiry(expired: expired, now: now, soon: now + 5 * 60)
82
+ end
83
+
84
+ # Currency
85
+ self.currency ||= CURRENCIES.sample
86
+
87
+ # Amount
88
+ self.amount ||= rand(100..99_999)
89
+
90
+ # Name
91
+
92
+ # Device Manufacturer Identification
93
+ self.dm_id ||= Helper.bytestring_to_hex(PedicelPay.config[:random].bytes(5))
94
+
95
+ # Cryptogram
96
+ self.cryptogram ||= Base64.strict_encode64(PedicelPay.config[:random].bytes(10))
97
+
98
+ # ECI
99
+ self.eci ||= %w[05 06 07].sample
100
+
101
+ self
102
+ end
103
+
104
+ def card_expired?(now)
105
+ Time.parse(expired) <= now
106
+ end
107
+
108
+ def self.sample_expiry(expired: nil, now: nil, soon: nil)
109
+ # WARNING: Time calculations ahead!
110
+ # Think very carefully about all the crazy corner cases.
111
+
112
+ now ||= Time.now
113
+ soon ||= now + 5 * 60
114
+
115
+ year = sample_expiry_year(expired: expired, soon: soon)
116
+ month = sample_expiry_month(expired: expired, year: year, now: now, soon: soon)
117
+
118
+ require 'date'
119
+ Date.civil(year, month, -1).strftime('%y%m%d')
120
+ end
121
+
122
+ def self.sample_expiry_year(expired: nil, soon:)
123
+ # WARNING: Time calculations ahead!
124
+ # Think very carefully about all the crazy corner cases.
125
+
126
+ case expired
127
+ when nil then -5..6
128
+ when true then -5..0
129
+ when false then 0..6
130
+ end
131
+ .map { |i| soon.year + i }
132
+ .to_a.sample
133
+ end
134
+
135
+ def self.sample_expiry_month(expired: nil, year:, now:, soon:)
136
+ # WARNING: Time calculations ahead!
137
+ # Think very carefully about all the crazy corner cases.
138
+
139
+ case expired
140
+ when nil
141
+ 1..12
142
+ when true
143
+ year < now.year ? 1..12 : 1..(now.month - 1)
144
+ when false
145
+ raise DateError, 'cannot expire in a soon future year' if expired && year > soon.year
146
+
147
+ year == soon.year ? 1..soon.month : 1..12
148
+ end
149
+ .to_a.sample
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,36 @@
1
+ module PedicelPay
2
+ class TokenHeader
3
+ Error = Class.new(PedicelPay::Error)
4
+
5
+ attr_accessor \
6
+ :data_hash,
7
+ :ephemeral_pubkey,
8
+ :pubkey_hash,
9
+ :transaction_id
10
+
11
+ def initialize(data_hash: nil, ephemeral_pubkey: nil, pubkey_hash: nil, transaction_id: nil)
12
+ @data_hash, @ephemeral_pubkey, @pubkey_hash, @transaction_id = \
13
+ data_hash, ephemeral_pubkey, pubkey_hash, transaction_id
14
+ end
15
+
16
+ def to_hash
17
+ raise Error, 'missing ephemeral_pubkey' unless ephemeral_pubkey
18
+
19
+ result = {
20
+ 'ephemeralPublicKey' => Base64.strict_encode64(Helper.ec_key_to_pkey_public_key(ephemeral_pubkey).to_der),
21
+ 'publicKeyHash' => pubkey_hash,
22
+ 'transactionId' => Helper.bytestring_to_hex(transaction_id),
23
+ }
24
+ result.merge!('applicationData' => data_hash) if data_hash
25
+
26
+ result
27
+ end
28
+
29
+ def sample
30
+ self.transaction_id ||= PedicelPay.config[:random].bytes(32)
31
+
32
+ self
33
+ end
34
+ end
35
+ end
36
+
@@ -0,0 +1,3 @@
1
+ module PedicelPay
2
+ VERSION = '0.0.2'
3
+ end
@@ -0,0 +1,43 @@
1
+ require 'openssl'
2
+
3
+ module PedicelPay
4
+ class Error < StandardError; end
5
+ end
6
+
7
+ require 'pedicel-pay/backend'
8
+ require 'pedicel-pay/client'
9
+ require 'pedicel-pay/token'
10
+
11
+ module PedicelPay
12
+ EC_CURVE = 'prime256v1'
13
+
14
+ DEFAULTS = {
15
+ oid: {
16
+ intermediate_certificate: '1.2.840.113635.100.6.2.14',
17
+ leaf_certificate: '1.2.840.113635.100.6.29',
18
+ merchant_identifier_field: '1.2.840.113635.100.6.32',
19
+ },
20
+ subject: {
21
+ ca: OpenSSL::X509::Name.parse('/C=DK/O=Pedicel Inc./OU=Pedicel Certification Authority/CN=Pedicel Root CA - G3'),
22
+ intermediate: OpenSSL::X509::Name.parse('/C=DK/O=Pedicel Inc./OU=Pedicel Certification Authority/CN=Pedicel Application Integration CA - G3'),
23
+ leaf: OpenSSL::X509::Name.parse('/C=DK/O=Pedicel Inc./OU=pOS Systems/CN=ecc-smp-broker-sign_UC4-PROD'),
24
+ csr: OpenSSL::X509::Name.parse('/CN=merchant-url.tld'),
25
+ client: OpenSSL::X509::Name.parse('/UID=merchant-url.tld.pedicel-merchant.PedicelMerchant/CN=Merchant ID: merchant-url.tld.pedicel-merchant.PedicelMerchant/OU=1W2X3Y4Z5A/O=PedicelMerchant Inc./C=DK'),
26
+ },
27
+ random: Random.new,
28
+ valid: Time.new(Time.now.year - 1)..Time.new(Time.now.year + 2),
29
+ }.freeze
30
+
31
+ def self.config
32
+ @@config ||= DEFAULTS.dup
33
+ end
34
+ end
35
+
36
+ # Monkey-patch to make OpenSSL::X509::Certificate#sign work.
37
+ if OpenSSL::PKey::EC.new.respond_to?(:private_key?) && !OpenSSL::PKey::EC.new.respond_to?(:private?)
38
+ class OpenSSL::PKey::EC
39
+ def private?
40
+ private_key?
41
+ end
42
+ end
43
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pedicel-pay
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clearhaus A/S
@@ -24,20 +24,6 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '1.16'
27
- - !ruby/object:Gem::Dependency
28
- name: pry
29
- requirement: !ruby/object:Gem::Requirement
30
- requirements:
31
- - - ">="
32
- - !ruby/object:Gem::Version
33
- version: '0'
34
- type: :development
35
- prerelease: false
36
- version_requirements: !ruby/object:Gem::Requirement
37
- requirements:
38
- - - ">="
39
- - !ruby/object:Gem::Version
40
- version: '0'
41
27
  - !ruby/object:Gem::Dependency
42
28
  name: pedicel
43
29
  requirement: !ruby/object:Gem::Requirement
@@ -56,41 +42,46 @@ dependencies:
56
42
  name: thor
57
43
  requirement: !ruby/object:Gem::Requirement
58
44
  requirements:
59
- - - ">="
45
+ - - "~>"
60
46
  - !ruby/object:Gem::Version
61
- version: '0'
47
+ version: '0.20'
62
48
  type: :runtime
63
49
  prerelease: false
64
50
  version_requirements: !ruby/object:Gem::Requirement
65
51
  requirements:
66
- - - ">="
52
+ - - "~>"
67
53
  - !ruby/object:Gem::Version
68
- version: '0'
54
+ version: '0.20'
69
55
  - !ruby/object:Gem::Dependency
70
56
  name: openssl
71
57
  requirement: !ruby/object:Gem::Requirement
72
58
  requirements:
73
- - - ">="
59
+ - - "~>"
74
60
  - !ruby/object:Gem::Version
75
- version: '0'
61
+ version: '2.1'
76
62
  type: :runtime
77
63
  prerelease: false
78
64
  version_requirements: !ruby/object:Gem::Requirement
79
65
  requirements:
80
- - - ">="
66
+ - - "~>"
81
67
  - !ruby/object:Gem::Version
82
- version: '0'
68
+ version: '2.1'
83
69
  description:
84
70
  email: hello@clearhaus.com
85
- executables: []
71
+ executables:
72
+ - pedicel-pay
86
73
  extensions: []
87
74
  extra_rdoc_files: []
88
75
  files:
89
- - Dockerfile
90
- - Gemfile
91
- - LICENSE.txt
92
- - README.md
93
- - pedicel-pay.gemspec
76
+ - exe/pedicel-pay
77
+ - lib/pedicel-pay.rb
78
+ - lib/pedicel-pay/backend.rb
79
+ - lib/pedicel-pay/client.rb
80
+ - lib/pedicel-pay/helper.rb
81
+ - lib/pedicel-pay/token.rb
82
+ - lib/pedicel-pay/token_data.rb
83
+ - lib/pedicel-pay/token_header.rb
84
+ - lib/pedicel-pay/version.rb
94
85
  homepage: https://github.com/clearhaus/pedicel-pay
95
86
  licenses:
96
87
  - MIT
data/Dockerfile DELETED
@@ -1,9 +0,0 @@
1
- FROM ruby:2.4-alpine
2
-
3
- LABEL maintainer="Clearhaus"
4
-
5
- WORKDIR /opt/pedicel-pay
6
- COPY . /opt/pedicel-pay
7
- RUN bundle install
8
-
9
- ENTRYPOINT ["/opt/pedicel-pay/exe/pedicel-pay"]
data/Gemfile DELETED
@@ -1,3 +0,0 @@
1
- source 'https://rubygems.org'
2
-
3
- gemspec
data/LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2018 Clearhaus A/S
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in
13
- all copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
- THE SOFTWARE.
data/README.md DELETED
@@ -1,99 +0,0 @@
1
- # PedicelPay
2
-
3
- A tool to handle the server and client side of Apple Pay. It consist of both a
4
- CLI part and a Ruby library e.g. for testing purposes.
5
-
6
- ## Usage (CLI)
7
-
8
- ### Setup a backend and a client
9
-
10
- 1. Generate a backend (Apple side of Apple Pay):
11
-
12
- $ pedicel-pay generate-backend
13
-
14
- Creates these files:
15
- * `ca.key`
16
- * `ca-certificate.pem`
17
- * `intermediate.key`
18
- * `intermediate-certificate.pem`
19
- * `leaf.key`
20
- * `leaf-certificate.pem`
21
-
22
- 2. Generate a client (merchant side of Apple Pay):
23
-
24
- $ pedicel-pay generate-client
25
-
26
- Creates `client.key` and `client-certificate.pem`.
27
-
28
-
29
- ### Create tokens
30
-
31
- $ pedicel-pay generate-token \
32
- --pan=4111111111111111 \
33
- --expiry=$(date -d 'next year' +%y%m%d) \
34
- --amount=1234 \
35
- --currency=978
36
-
37
- Specify some values, sample remaining:
38
-
39
- $ pedicel-pay generate-token \
40
- --pan=4111111111111111 \
41
- --sample
42
-
43
- ### Decrypt tokens
44
-
45
- $ echo $TOKEN | pedicel-pay decrypt-token
46
-
47
-
48
- ## Usage (Ruby)
49
-
50
- ### Setup a backend and a client
51
-
52
- ```ruby
53
- backend = PedicelPay::Backend.generate
54
- client = backend.generate_client
55
- ```
56
-
57
- ### Create tokens
58
-
59
- Sample data
60
-
61
- ```ruby
62
- token = PedicelPay::Token.new.sample
63
- backend.encrypt(token: token, recipient: client)
64
- backend.sign(token)
65
- puts token.to_json
66
- ```
67
-
68
- or decide:
69
-
70
- ```ruby
71
- token = PedicelPay::Token.new
72
-
73
- token.unencrypted_data.pan = '4111111111111111'
74
- token.unencrypted_data.currency = '987' # EUR
75
- token.unencrypted_data.amount = 1234 # 12.34 EUR
76
- token.sample # Sample remaining.
77
-
78
- backend.encrypt(token: token, recipient: client)
79
- backend.sign(token)
80
- puts token.to_json
81
- ```
82
-
83
- The JSON formatted Payment Token; refer to
84
- https://developer.apple.com/library/content/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html
85
-
86
-
87
- ### Decrypt tokens
88
-
89
- Using the `client` (if it knows the CA cert):
90
-
91
- ```ruby
92
- client.decrypt(JSON.parse(token.to_json))
93
- ```
94
-
95
- To decrypt the token data by hand, use these values:
96
- * The client's secret key `client.key`.
97
- * The merchant ID `client.merchant_id` or client's certificate (containing the
98
- merchant ID) `client.certificate`.
99
- * Use `backend.ca_certificate` as Apple Root CA G3 certificate.
data/pedicel-pay.gemspec DELETED
@@ -1,26 +0,0 @@
1
- lib = File.expand_path('../lib', __FILE__)
2
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
-
4
- require 'pedicel-pay/version'
5
-
6
- Gem::Specification.new do |s|
7
- s.name = 'pedicel-pay'
8
- s.version = PedicelPay::VERSION
9
- s.author = 'Clearhaus A/S'
10
- s.email = 'hello@clearhaus.com'
11
-
12
- s.summary = 'Backend and client part of Apple Pay'
13
- s.homepage = 'https://github.com/clearhaus/pedicel-pay'
14
- s.license = 'MIT'
15
-
16
- s.files = `ls`.split.reject {|f| f.match(/^spec\//) }
17
- s.bindir = 'exe'
18
- s.executables = s.files.grep(/^exe\//) { |f| File.basename(f) }
19
- s.require_paths = ['lib']
20
-
21
- s.add_development_dependency 'bundler', '~> 1.16'
22
- s.add_development_dependency 'pry'
23
- s.add_runtime_dependency 'pedicel', '~> 0.0.2'
24
- s.add_runtime_dependency 'thor'
25
- s.add_runtime_dependency 'openssl' # for ruby <= 2.3
26
- end