pedicel-pay 0.0.1 → 0.0.2

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 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