mastercard-client-encryption 1.1.0 → 1.2.0

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: b77cd2d8a1ded5e2456a290e91add5537e3de8ce397e02c049b58a23c4d93f79
4
- data.tar.gz: 82350cf1e7aafc602f7efccdf94b271ef218b77fa26b90be3bd7baac18855c27
3
+ metadata.gz: 498fe9a62ff880357316d0dd97d78d27dae85bca253702d905e2769087579ba2
4
+ data.tar.gz: 22a35a9afebde5f8f746067154c339ced42edd4b0d4ec0d6fa920963f1de5abd
5
5
  SHA512:
6
- metadata.gz: a56bec98e3a1ed10af314ecf1c318abbf88b36a39dab24f3f888ed2a05d8767befde6fc807022a57ae1d33d6a50c163903bb436f52de480683be9d59f2044c43
7
- data.tar.gz: cf09bf8da562269a9f481bea48662cf44c0381cdbfed88945436d627bea23cc8bb3b4fb63247042f7c3fbffc87ece128109ec128b832a868588f7db478a52012
6
+ metadata.gz: 3fe63908d6249e94fc6db683aefe97a82622ee4c8c59eca8e8a7cd21f7d79a96c0476f6efb6bc9362d2903eed8afc0a0d04a37b2b8a44ed438989560c5b47034
7
+ data.tar.gz: 879b24f693402374293b543d7a5bbacd4b05b12b23f4586a85ee2c078a3631fa16159051cc2acac2f07820fa48ddfdf6f1205dcd3419cf9cd2d6851af0ebb383
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+ require 'base64'
6
+ require 'securerandom'
7
+ require_relative '../utils/utils'
8
+ require_relative '../utils/openssl_rsa_oaep'
9
+
10
+ module McAPI
11
+ module Encryption
12
+ #
13
+ # JWE Crypto class provide RSA/AES encrypt/decrypt methods
14
+ #
15
+ class JweCrypto
16
+ #
17
+ # Create a new instance with the provided config
18
+ #
19
+ # @param [Hash] config configuration object
20
+ #
21
+ def initialize(config)
22
+ @encoding = config['dataEncoding']
23
+ @cert = OpenSSL::X509::Certificate.new(IO.binread(config['encryptionCertificate']))
24
+ if config['privateKey']
25
+ @private_key = OpenSSL::PKey.read(IO.binread(config['privateKey']))
26
+ elsif config['keyStore']
27
+ @private_key = OpenSSL::PKCS12.new(IO.binread(config['keyStore']), config['keyStorePassword']).key
28
+ end
29
+ @encrypted_value_field_name = config['encryptedValueFieldName'] || 'encryptedData'
30
+ @public_key_fingerprint = compute_public_fingerprint
31
+ end
32
+
33
+ #
34
+ # Perform data encryption:
35
+ #
36
+ # @param [String] data json string to encrypt
37
+ #
38
+ # @return [Hash] encrypted data
39
+ #
40
+ def encrypt_data(data:)
41
+ cek = SecureRandom.random_bytes(32)
42
+ iv = SecureRandom.random_bytes(12)
43
+
44
+ md = OpenSSL::Digest::SHA256
45
+ encrypted_key = @cert.public_key.public_encrypt_oaep(cek, '', md, md)
46
+
47
+ header = generate_header('RSA-OAEP-256', 'A256GCM')
48
+ json_hdr = header.to_json
49
+ auth_data = jwe_encode(json_hdr)
50
+
51
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
52
+ cipher.encrypt
53
+ cipher.key = cek
54
+ cipher.iv = iv
55
+ cipher.padding = 0
56
+ cipher.auth_data = auth_data
57
+ cipher_text = cipher.update(data) + cipher.final
58
+
59
+ payload = generate_serialization(json_hdr, encrypted_key, cipher_text, iv, cipher.auth_tag)
60
+ {
61
+ @encrypted_value_field_name => payload
62
+ }
63
+ end
64
+
65
+ #
66
+ # Perform data decryption
67
+ #
68
+ # @param [String] encrypted_data encrypted data to decrypt
69
+ #
70
+ # @return [String] Decrypted JSON object
71
+ #
72
+ def decrypt_data(encrypted_data:)
73
+ parts = encrypted_data.split('.')
74
+ encrypted_header, encrypted_key, initialization_vector, cipher_text, authentication_tag = parts
75
+
76
+ jwe_header = jwe_decode(encrypted_header)
77
+ encrypted_key = jwe_decode(encrypted_key)
78
+ iv = jwe_decode(initialization_vector)
79
+ cipher_text = jwe_decode(cipher_text)
80
+ cipher_tag = jwe_decode(authentication_tag)
81
+
82
+ md = OpenSSL::Digest::SHA256
83
+ cek = @private_key.private_decrypt_oaep(encrypted_key, '', md, md)
84
+
85
+ enc_method = JSON.parse(jwe_header)['enc']
86
+
87
+ if enc_method == "A256GCM"
88
+ enc_string = "aes-256-gcm"
89
+ elsif enc_method == "A128CBC-HS256"
90
+ cek = cek.byteslice(16, cek.length)
91
+ enc_string = "aes-128-cbc"
92
+ else
93
+ raise Exception, "Encryption method '#{enc_method}' not supported."
94
+ end
95
+
96
+ cipher = OpenSSL::Cipher.new(enc_string)
97
+ cipher.decrypt
98
+ cipher.key = cek
99
+ cipher.iv = iv
100
+ cipher.padding = 0
101
+ if enc_method == "A256GCM"
102
+ cipher.auth_data = encrypted_header
103
+ cipher.auth_tag = cipher_tag
104
+ end
105
+
106
+ cipher.update(cipher_text) + cipher.final
107
+ end
108
+
109
+ private
110
+
111
+ #
112
+ # Compute the fingerprint for the provided public key
113
+ #
114
+ # @return [String] the computed fingerprint encoded using the configured encoding
115
+ #
116
+ def compute_public_fingerprint
117
+ OpenSSL::Digest::SHA256.new(@cert.public_key.to_der).to_s
118
+ end
119
+
120
+ #
121
+ # Generate the JWE header for the provided encryption algorithm and encryption method
122
+ #
123
+ # @param [String] alg the cryptographic algorithm used to encrypt the value of the CEK
124
+ # @param [String] enc the content encryption algorithm used to perform authenticated encryption on the plaintext
125
+ #
126
+ # @return [Hash] the JWE header
127
+ #
128
+ def generate_header(alg, enc)
129
+ { alg: alg, enc: enc, kid: @public_key_fingerprint, cty: 'application/json' }
130
+ end
131
+
132
+ #
133
+ # URL safe Base64 encode the provided value
134
+ #
135
+ # @param [String] value to be encoded
136
+ #
137
+ # @return [String] URL safe Base64 encoded value
138
+ #
139
+ def jwe_encode(value)
140
+ ::Base64.urlsafe_encode64(value).delete('=')
141
+ end
142
+
143
+ #
144
+ # URL safe Base64 decode the provided value
145
+ #
146
+ # @param [String] value to be decoded
147
+ #
148
+ # @return [String] URL safe Base64 decoded value
149
+ #
150
+ def jwe_decode(value)
151
+ padlen = 4 - (value.length % 4)
152
+ if padlen < 4
153
+ pad = '=' * padlen
154
+ value += pad
155
+ end
156
+ ::Base64.urlsafe_decode64(value)
157
+ end
158
+
159
+ #
160
+ # Generate JWE compact payload from the provided values
161
+ #
162
+ # @param [String] hdr JWE header
163
+ # @param [String] cek content encryption key
164
+ # @param [String] content cipher text
165
+ # @param [String] iv initialization vector
166
+ # @param [String] tag cipher auth tag
167
+ #
168
+ # @return [String] URL safe Base64 decoded value
169
+ #
170
+ def generate_serialization(hdr, cek, content, iv, tag)
171
+ [hdr, cek, iv, content, tag].map { |piece| jwe_encode(piece) }.join '.'
172
+ end
173
+ end
174
+ end
175
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'crypto/crypto'
4
4
  require_relative 'utils/hash.ext'
5
+ require_relative 'utils/utils'
5
6
  require 'json'
6
7
 
7
8
  module McAPI
@@ -36,7 +37,7 @@ module McAPI
36
37
  #
37
38
  def encrypt(endpoint, header, body)
38
39
  body = JSON.parse(body) if body.is_a?(String)
39
- config = config?(endpoint)
40
+ config = McAPI::Utils.config?(endpoint, @config)
40
41
  body_map = body
41
42
  if config
42
43
  if !@is_with_header
@@ -50,7 +51,7 @@ module McAPI
50
51
  end
51
52
  end
52
53
  end
53
- { header: header, body: config ? compute_body(config['toEncrypt'], body_map) { body.json } : body.json }
54
+ { header: header, body: config ? McAPI::Utils.compute_body(config['toEncrypt'], body_map) { body.json } : body.json }
54
55
  end
55
56
 
56
57
  #
@@ -62,7 +63,7 @@ module McAPI
62
63
  #
63
64
  def decrypt(response)
64
65
  response = JSON.parse(response)
65
- config = config?(response['request']['url'])
66
+ config = McAPI::Utils.config?(response['request']['url'], @config)
66
67
  body_map = response
67
68
  if config
68
69
  if !@is_with_header
@@ -71,31 +72,31 @@ module McAPI
71
72
  end
72
73
  else
73
74
  config['toDecrypt'].each do |v|
74
- elem = elem_from_path(v['obj'], response['body'])
75
+ elem = McAPI::Utils.elem_from_path(v['obj'], response['body'])
75
76
  decrypt_with_header(v, elem, response) if elem[:node][v['element']]
76
77
  end
77
78
  end
78
79
  end
79
- response['body'] = compute_body(config['toDecrypt'], body_map) { response['body'] } unless config.nil?
80
+ response['body'] = McAPI::Utils.compute_body(config['toDecrypt'], body_map) { response['body'] } unless config.nil?
80
81
  JSON.generate(response)
81
82
  end
82
83
 
83
84
  private
84
85
 
85
86
  def encrypt_with_body(path, body)
86
- elem = elem_from_path(path['element'], body)
87
+ elem = McAPI::Utils.elem_from_path(path['element'], body)
87
88
  return unless elem && elem[:node]
88
89
 
89
90
  encrypted_data = @crypto.encrypt_data(data: JSON.generate(elem[:node]))
90
91
  body = McAPI::Utils.mutate_obj_prop(path['obj'], encrypted_data, body)
91
- unless json_root?(path['obj']) || path['element'] == "#{path['obj']}.#{@config['encryptedValueFieldName']}"
92
+ unless McAPI::Utils.json_root?(path['obj']) || path['element'] == "#{path['obj']}.#{@config['encryptedValueFieldName']}"
92
93
  McAPI::Utils.delete_node(path['element'], body)
93
94
  end
94
95
  body
95
96
  end
96
97
 
97
98
  def encrypt_with_header(path, enc_params, header, body)
98
- elem = elem_from_path(path['element'], body)
99
+ elem = McAPI::Utils.elem_from_path(path['element'], body)
99
100
  return unless elem && elem[:node]
100
101
 
101
102
  encrypted_data = @crypto.encrypt_data(data: JSON.generate(elem[:node]), encryption_params: enc_params)
@@ -105,7 +106,7 @@ module McAPI
105
106
  end
106
107
 
107
108
  def decrypt_with_body(path, body)
108
- elem = elem_from_path(path['element'], body)
109
+ elem = McAPI::Utils.elem_from_path(path['element'], body)
109
110
  return unless elem && elem[:node]
110
111
 
111
112
  decrypted = @crypto.decrypt_data(elem[:node][@config['encryptedValueFieldName']],
@@ -130,46 +131,12 @@ module McAPI
130
131
  response['headers'][@config['oaepHashingAlgorithmHeaderName']][0]))
131
132
  end
132
133
 
133
- def elem_from_path(path, obj)
134
- parent = nil
135
- paths = path.split('.')
136
- if path && !paths.empty?
137
- paths.each do |e|
138
- parent = obj
139
- obj = json_root?(e) ? obj : obj[e]
140
- end
141
- end
142
- { node: obj, parent: parent }
143
- rescue StandardError
144
- nil
145
- end
146
-
147
- def config?(endpoint)
148
- return unless endpoint
149
-
150
- endpoint = endpoint.split('?').shift
151
- conf = @config['paths'].select { |e| endpoint.match(e['path']) }
152
- conf.empty? ? nil : conf[0]
153
- end
154
-
155
134
  def set_header(header, params)
156
135
  header[@config['encryptedKeyHeaderName']] = params[:encoded][:encryptedKey]
157
136
  header[@config['ivHeaderName']] = params[:encoded][:iv]
158
137
  header[@config['oaepHashingAlgorithmHeaderName']] = params[:oaepHashingAlgorithm].sub('-', '')
159
138
  header[@config['publicKeyFingerprintHeaderName']] = params[:publicKeyFingerprint]
160
139
  end
161
-
162
- def json_root?(elem)
163
- elem == '$'
164
- end
165
-
166
- def compute_body(config_param, body_map)
167
- encryption_param?(config_param, body_map) ? body_map[0] : yield
168
- end
169
-
170
- def encryption_param?(enc_param, body_map)
171
- enc_param.length == 1 && body_map.length == 1
172
- end
173
140
  end
174
141
  end
175
142
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'crypto/jwe-crypto'
4
+ require_relative 'utils/hash.ext'
5
+ require 'json'
6
+
7
+ module McAPI
8
+ module Encryption
9
+ #
10
+ # Performs JWE encryption on HTTP payloads.
11
+ #
12
+ class JweEncryption
13
+ #
14
+ # Create a new instance with the provided configuration
15
+ #
16
+ # @param [Hash] config Configuration object
17
+ #
18
+ def initialize(config)
19
+ @config = config
20
+ @crypto = McAPI::Encryption::JweCrypto.new(config)
21
+ end
22
+
23
+ #
24
+ # Encrypt parts of a HTTP request using the given config
25
+ #
26
+ # @param [String] endpoint HTTP URL for the current call
27
+ # @param [Object|nil] header HTTP header
28
+ # @param [String,Hash] body HTTP body
29
+ #
30
+ # @return [Hash] Hash with two keys:
31
+ # * :header header with encrypted value (if configured with header)
32
+ # * :body encrypted body
33
+ #
34
+ def encrypt(endpoint, body)
35
+ body = JSON.parse(body) if body.is_a?(String)
36
+ config = McAPI::Utils.config?(endpoint, @config)
37
+ body_map = body
38
+ if config
39
+ body_map = config['toEncrypt'].map do |v|
40
+ encrypt_with_body(v, body)
41
+ end
42
+ end
43
+ { body: config ? McAPI::Utils.compute_body(config['toEncrypt'], body_map) { body.json } : body.json }
44
+ end
45
+
46
+ #
47
+ # Decrypt part of the HTTP response using the given config
48
+ #
49
+ # @param [Object] response object as obtained from the http client
50
+ #
51
+ # @return [Object] response object with decrypted fields
52
+ #
53
+ def decrypt(response)
54
+ response = JSON.parse(response)
55
+ config = McAPI::Utils.config?(response['request']['url'], @config)
56
+ body_map = response
57
+ if config
58
+ body_map = config['toDecrypt'].map do |v|
59
+ decrypt_with_body(v, response['body'])
60
+ end
61
+ end
62
+ response['body'] = McAPI::Utils.compute_body(config['toDecrypt'], body_map) { response['body'] } unless config.nil?
63
+ JSON.generate(response)
64
+ end
65
+
66
+ private
67
+
68
+ def encrypt_with_body(path, body)
69
+ elem = McAPI::Utils.elem_from_path(path['element'], body)
70
+ return unless elem && elem[:node]
71
+
72
+ encrypted_data = @crypto.encrypt_data(data: JSON.generate(elem[:node]))
73
+ body = McAPI::Utils.mutate_obj_prop(path['obj'], encrypted_data, body)
74
+ unless McAPI::Utils.json_root?(path['obj']) || path['element'] == "#{path['obj']}.#{@config['encryptedValueFieldName']}"
75
+ McAPI::Utils.delete_node(path['element'], body)
76
+ end
77
+ body
78
+ end
79
+
80
+ def decrypt_with_body(path, body)
81
+ elem = McAPI::Utils.elem_from_path(path['element'], body)
82
+ return unless elem && elem[:node]
83
+
84
+ decrypted = @crypto.decrypt_data(encrypted_data: elem[:node][@config['encryptedValueFieldName']])
85
+ begin
86
+ decrypted = JSON.parse(decrypted)
87
+ rescue JSON::ParserError
88
+ # ignored
89
+ end
90
+
91
+ McAPI::Utils.mutate_obj_prop(path['obj'], decrypted, body, path['element'], @encryption_response_properties)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'field_level_encryption'
4
+ require_relative 'jwe_encryption'
4
5
  require_relative 'utils/utils'
5
6
 
6
7
  module McAPI
@@ -27,14 +28,36 @@ module McAPI
27
28
  McAPI::Encryption::OpenAPIInterceptor.init_deserialize swagger_client
28
29
  end
29
30
 
31
+ #
32
+ # Install the JWE encryption in the OpenAPI HTTP client
33
+ # adding encryption/decryption capabilities for the request/response payload.
34
+ #
35
+ # @param [Object] swagger_client OpenAPI service client (it can be generated by the swagger code generator)
36
+ # @param [Hash] config configuration object describing which field to enable encryption/decryption
37
+ #
38
+ def install_jwe_encryption(swagger_client, config)
39
+ jwe = McAPI::Encryption::JweEncryption.new(config)
40
+ # Hooking ApiClient#call_api
41
+ hook_call_api jwe
42
+ # Hooking ApiClient#deserialize
43
+ hook_deserialize jwe
44
+ McAPI::Encryption::OpenAPIInterceptor.init_call_api swagger_client
45
+ McAPI::Encryption::OpenAPIInterceptor.init_deserialize swagger_client
46
+ end
47
+
30
48
  private
31
49
 
32
- def hook_call_api(fle)
50
+ def hook_call_api(enc)
33
51
  self.class.send :define_method, :init_call_api do |client|
34
52
  client.define_singleton_method(:call_api) do |http_method, path, opts|
35
53
  if opts && opts[:body]
36
- encrypted = fle.encrypt(path, opts[:header_params], opts[:body])
37
- opts[:body] = JSON.generate(encrypted[:body])
54
+ if enc.instance_of? McAPI::Encryption::FieldLevelEncryption
55
+ encrypted = enc.encrypt(path, opts[:header_params], opts[:body])
56
+ opts[:body] = JSON.generate(encrypted[:body])
57
+ else
58
+ encrypted = enc.encrypt(path, opts[:body])
59
+ opts[:body] = JSON.generate(encrypted[:body])
60
+ end
38
61
  end
39
62
  # noinspection RubySuperCallWithoutSuperclassInspection
40
63
  super(http_method, path, opts)
@@ -42,15 +65,20 @@ module McAPI
42
65
  end
43
66
  end
44
67
 
45
- def hook_deserialize(fle)
68
+ def hook_deserialize(enc)
46
69
  self.class.send :define_method, :init_deserialize do |client|
47
70
  client.define_singleton_method(:deserialize) do |response, return_type|
48
71
  if response&.body
49
72
  endpoint = response.request.base_url.sub client.config.base_url, ''
50
- to_decrypt = { headers: McAPI::Utils.parse_header(response.options[:response_headers]),
51
- request: { url: endpoint },
52
- body: JSON.parse(response.body) }
53
- decrypted = fle.decrypt(JSON.generate(to_decrypt, symbolize_names: false))
73
+ if enc.instance_of? McAPI::Encryption::FieldLevelEncryption
74
+ to_decrypt = { headers: McAPI::Utils.parse_header(response.options[:response_headers]),
75
+ request: { url: endpoint },
76
+ body: JSON.parse(response.body) }
77
+ else
78
+ to_decrypt = { request: { url: endpoint },
79
+ body: JSON.parse(response.body) }
80
+ end
81
+ decrypted = enc.decrypt(JSON.generate(to_decrypt, symbolize_names: false))
54
82
  body = JSON.generate(JSON.parse(decrypted)['body'])
55
83
  response.options[:response_body] = JSON.generate(JSON.parse(body))
56
84
  end
@@ -146,5 +146,45 @@ module McAPI
146
146
  end
147
147
  header
148
148
  end
149
+
150
+ #
151
+ # Get an element from the JSON path
152
+ #
153
+ def self.elem_from_path(path, obj)
154
+ parent = nil
155
+ paths = path.split('.')
156
+ if path && !paths.empty?
157
+ paths.each do |e|
158
+ parent = obj
159
+ obj = json_root?(e) ? obj : obj[e]
160
+ end
161
+ end
162
+ { node: obj, parent: parent }
163
+ rescue StandardError
164
+ nil
165
+ end
166
+
167
+ #
168
+ # Check whether the encryption/decryption path refers to the root element
169
+ #
170
+ def self.json_root?(elem)
171
+ elem == '$'
172
+ end
173
+
174
+ def self.config?(endpoint, config)
175
+ return unless endpoint
176
+
177
+ endpoint = endpoint.split('?').shift
178
+ conf = config['paths'].select { |e| endpoint.match(e['path']) }
179
+ conf.empty? ? nil : conf[0]
180
+ end
181
+
182
+ def self.compute_body(config_param, body_map)
183
+ encryption_param?(config_param, body_map) ? body_map[0] : yield
184
+ end
185
+
186
+ def self.encryption_param?(enc_param, body_map)
187
+ enc_param.length == 1 && body_map.length == 1
188
+ end
149
189
  end
150
190
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mastercard-client-encryption
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mastercard
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-17 00:00:00.000000000 Z
11
+ date: 2022-07-21 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: hamster
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: bundler
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -73,7 +87,9 @@ extensions: []
73
87
  extra_rdoc_files: []
74
88
  files:
75
89
  - lib/mcapi/encryption/crypto/crypto.rb
90
+ - lib/mcapi/encryption/crypto/jwe-crypto.rb
76
91
  - lib/mcapi/encryption/field_level_encryption.rb
92
+ - lib/mcapi/encryption/jwe_encryption.rb
77
93
  - lib/mcapi/encryption/openapi_interceptor.rb
78
94
  - lib/mcapi/encryption/utils/hash.ext.rb
79
95
  - lib/mcapi/encryption/utils/openssl_rsa_oaep.rb