mastercard-client-encryption 1.1.0 → 1.2.0

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