legion-crypt 1.5.13 → 1.5.38

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.
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
- require 'uri'
5
- require 'vault'
4
+ require 'legion/crypt/vault_operation'
5
+ require 'legion/crypt/vault_client'
6
6
 
7
7
  module Legion
8
8
  module Crypt
@@ -16,34 +16,30 @@ module Legion
16
16
  end
17
17
 
18
18
  def connect_vault
19
- @sessions = []
20
- vault_settings = Legion::Settings[:crypt][:vault]
21
- ::Vault.address = resolve_vault_address(vault_settings)
22
- ::Vault.ssl_verify = resolve_ssl_verify(vault_settings[:tls])
23
- namespace = vault_settings[:vault_namespace]
24
- log.info "Vault connection requested address=#{::Vault.address} namespace=#{namespace || 'none'} ssl_verify=#{::Vault.ssl_verify}"
25
-
26
- Legion::Settings[:crypt][:vault][:token] = ENV['VAULT_DEV_ROOT_TOKEN_ID'] if ENV.key? 'VAULT_DEV_ROOT_TOKEN_ID'
27
- return nil if Legion::Settings[:crypt][:vault][:token].nil?
28
-
29
- ::Vault.token = Legion::Settings[:crypt][:vault][:token]
30
- ::Vault.namespace = namespace if namespace
19
+ return unless start_session_tracking
20
+
21
+ configuration = Legion::Settings[:crypt][:vault]
22
+ configuration[:token] = ENV['VAULT_DEV_ROOT_TOKEN_ID'] if ENV.key? 'VAULT_DEV_ROOT_TOKEN_ID'
23
+ return nil if configuration[:token].nil?
24
+
25
+ client = vault_client
26
+ log.info "Vault connection requested address=#{client.address} namespace=#{client.namespace || 'none'} ssl_verify=#{client.ssl_verify}"
31
27
  if vault_healthy?
32
- Legion::Settings[:crypt][:vault][:connected] = true
33
- log.info "Vault connected at #{::Vault.address} (namespace=#{namespace || 'none'})"
28
+ configuration[:connected] = true
29
+ log.info "Vault connected at #{client.address} (namespace=#{client.namespace || 'none'})"
34
30
  end
35
31
  rescue StandardError => e
36
- log_vault_connection_error(e)
32
+ handle_exception(e, level: :error, operation: 'crypt.vault.connect_vault')
37
33
  Legion::Settings[:crypt][:vault][:connected] = false
38
34
  false
39
35
  end
40
36
 
41
37
  def vault_healthy?
42
- ::Vault.sys.health_status.initialized?
43
- rescue ::Vault::HTTPError => e
38
+ vault_client.sys.health_status.initialized?
39
+ rescue Legion::Crypt::Vault::HTTPError => e
44
40
  # 429 = standby, 472 = DR secondary, 473 = performance standby
45
41
  # All indicate an initialized, healthy Vault — just not the active node.
46
- return true if e.message =~ /\b(429|472|473)\b/
42
+ return true if [429, 472, 473].include?(e.code)
47
43
 
48
44
  raise
49
45
  end
@@ -51,7 +47,7 @@ module Legion
51
47
  def read(path, type = nil, cluster_name: nil)
52
48
  full_path = type.nil? || type.empty? ? path : "#{type}/#{path}"
53
49
  log_read_context(full_path, cluster_name: cluster_name)
54
- lease = logical_client(cluster_name: cluster_name).read(full_path)
50
+ lease = logical_client(cluster_name: cluster_name).read(path: full_path)
55
51
  if lease.nil?
56
52
  log_vault_debug("Vault read: #{full_path} returned nil")
57
53
  return nil
@@ -68,7 +64,7 @@ module Legion
68
64
 
69
65
  def get(path, cluster_name: nil)
70
66
  log.debug "Vault kv get: path=#{path}"
71
- result = kv_client(cluster_name: cluster_name).read(path)
67
+ result = kv_client(cluster_name: cluster_name).read(path: path)
72
68
  if result.nil?
73
69
  log.debug "Vault kv get: #{path} returned nil"
74
70
  return nil
@@ -83,7 +79,7 @@ module Legion
83
79
 
84
80
  def write(path, cluster_name: nil, **hash)
85
81
  log.info "Vault kv write requested path=#{path}"
86
- kv_client(cluster_name: cluster_name).write(path, **hash)
82
+ kv_client(cluster_name: cluster_name).write(path: path, data: hash)
87
83
  log.info "Vault kv write complete path=#{path}"
88
84
  rescue StandardError => e
89
85
  handle_exception(e, level: :error, operation: 'crypt.vault.write', path: path)
@@ -91,7 +87,7 @@ module Legion
91
87
  end
92
88
 
93
89
  def delete(path, cluster_name: nil)
94
- logical_client(cluster_name: cluster_name).delete(path)
90
+ logical_client(cluster_name: cluster_name).delete(path: path)
95
91
  log.info "Vault delete complete path=#{path}"
96
92
  { success: true, path: path }
97
93
  rescue StandardError => e
@@ -100,89 +96,149 @@ module Legion
100
96
  end
101
97
 
102
98
  def exist?(path, cluster_name: nil)
103
- !kv_client(cluster_name: cluster_name).read_metadata(path).nil?
99
+ !kv_client(cluster_name: cluster_name).read_metadata(path: path).nil?
104
100
  end
105
101
 
106
- def add_session(path:)
107
- @sessions ||= []
108
- @sessions.push(path)
102
+ def start_session_tracking
103
+ session_mutex.synchronize do
104
+ return false if crypt_stopping?
105
+
106
+ @sessions = []
107
+ @sessions_stopped = false
108
+ @session_generation = (@session_generation || 0) + 1
109
+ end
110
+ true
109
111
  end
110
112
 
111
- def close_sessions
112
- return if @sessions.nil?
113
+ def add_session(path:)
114
+ session_mutex.synchronize do
115
+ return if @sessions_stopped || crypt_stopping?
116
+
117
+ (@sessions ||= []).push(path)
118
+ end
119
+ end
113
120
 
121
+ def close_sessions(deadline: nil)
122
+ deadline ||= ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + VaultOperation::SHUTDOWN_TIMEOUT
123
+ pending = session_mutex.synchronize do
124
+ @sessions_stopped = true
125
+ @session_generation = (@session_generation || 0) + 1
126
+ detached = @sessions || []
127
+ @sessions = []
128
+ detached
129
+ end
114
130
  log.info 'Closing all Legion::Crypt vault sessions'
131
+ closed = []
132
+ pending.each do |session|
133
+ break if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
115
134
 
116
- @sessions.each do |session|
117
- close_session(session: session)
135
+ VaultOperation.with_timeout(deadline: deadline) { close_session(session: session) }
136
+ closed << session
137
+ rescue StandardError => e
138
+ handle_exception(e, level: :error, operation: 'crypt.vault.close_session')
118
139
  end
140
+ closed
119
141
  end
120
142
 
121
143
  def shutdown_renewer
122
- return unless Legion::Settings[:crypt][:vault][:connected]
123
- return if @renewer.nil?
144
+ renewer = session_mutex.synchronize do
145
+ @sessions_stopped = true
146
+ @session_generation = (@session_generation || 0) + 1
147
+ detached = @renewer
148
+ @renewer = nil
149
+ detached
150
+ end
151
+ return if renewer.nil?
124
152
 
125
153
  log.info 'Shutting down Legion::Crypt::Vault::Renewer'
126
- @renewer.cancel
154
+ renewer.cancel
127
155
  end
128
156
 
129
157
  def close_session(session:)
130
- ::Vault.sys.revoke(session)
158
+ vault_client.sys.revoke(lease_id: session)
131
159
  end
132
160
 
133
161
  def renew_session(session:)
134
- ::Vault.sys.renew(session)
162
+ vault_client.sys.renew(lease_id: session)
135
163
  end
136
164
 
137
165
  def renew_sessions(**_opts)
166
+ generation, pending = session_mutex.synchronize do
167
+ return [] if @sessions_stopped || crypt_stopping?
168
+
169
+ [@session_generation, (@sessions || []).dup]
170
+ end
138
171
  log.debug 'Vault renewal cycle start'
139
- result = if respond_to?(:connected_clusters) && connected_clusters.any?
140
- renew_cluster_tokens
141
- else
142
- @sessions.each do |session|
143
- renew_session(session: session)
144
- end
145
- end
172
+ return renew_cluster_tokens(generation: generation) if respond_to?(:connected_clusters) && connected_clusters.any?
173
+
174
+ renewed = []
175
+ pending.each do |session|
176
+ break unless session_maintenance_active?(generation: generation)
177
+
178
+ VaultOperation.with_timeout { renew_session(session: session) }
179
+ renewed << session
180
+ rescue StandardError => e
181
+ handle_exception(e, level: :error, operation: 'crypt.vault.renew_session')
182
+ end
146
183
  log.debug 'Vault renewal cycle complete'
147
- result
184
+ renewed
148
185
  rescue StandardError => e
149
186
  handle_exception(e, level: :error, operation: 'crypt.vault.renew_sessions')
150
187
  raise
151
188
  end
152
189
 
153
- def renew_cluster_tokens
190
+ def renew_cluster_tokens(generation: session_mutex.synchronize { @session_generation })
191
+ renewed = []
154
192
  connected_clusters.each_key do |name|
155
- client = vault_client(name)
156
- client.auth_token.renew_self
193
+ break unless session_maintenance_active?(generation: generation)
194
+
195
+ VaultOperation.with_timeout { vault_client(name).auth_token.renew_self }
196
+ renewed << name
157
197
  log.info "Vault token renewed for cluster #{name}"
158
198
  rescue StandardError => e
159
199
  log_vault_error(name, e)
160
200
  end
201
+ renewed
161
202
  end
162
203
 
163
204
  def vault_exists?(name)
164
- ::Vault.sys.mounts.key?(name.to_sym)
205
+ vault_client.sys.mounts.key?(name.to_sym)
165
206
  end
166
207
 
167
208
  private
168
209
 
210
+ def session_mutex
211
+ @session_mutex ||= Mutex.new
212
+ end
213
+
214
+ def crypt_stopping?
215
+ Legion::Crypt.respond_to?(:stopping) && Legion::Crypt.stopping
216
+ end
217
+
218
+ def session_maintenance_active?(generation:, **)
219
+ session_mutex.synchronize do
220
+ !@sessions_stopped && !crypt_stopping? && @session_generation == generation
221
+ end
222
+ end
223
+
169
224
  def kv_client(cluster_name: nil)
170
- if respond_to?(:connected_clusters) && connected_clusters.any?
171
- connected_vault_client(cluster_name).kv(settings[:kv_path])
225
+ mount = Legion::Settings[:crypt][:vault][:kv_path]
226
+ if respond_to?(:connected_clusters) && (clusters.any? || connected_clusters.any?)
227
+ connected_vault_client(cluster_name).kv(mount: mount)
172
228
  else
173
229
  raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if cluster_name
174
230
 
175
- ::Vault.kv(settings[:kv_path])
231
+ vault_client.kv(mount: mount)
176
232
  end
177
233
  end
178
234
 
179
235
  def logical_client(cluster_name: nil)
180
- if respond_to?(:connected_clusters) && connected_clusters.any?
236
+ if respond_to?(:connected_clusters) && (clusters.any? || connected_clusters.any?)
181
237
  connected_vault_client(cluster_name).logical
182
238
  else
183
239
  raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if cluster_name
184
240
 
185
- ::Vault.logical
241
+ vault_client.logical
186
242
  end
187
243
  end
188
244
 
@@ -191,16 +247,16 @@ module Legion
191
247
  client = connected_vault_client(cluster_name)
192
248
  client.respond_to?(:namespace) ? client.namespace : 'n/a'
193
249
  else
194
- 'n/a (global client)'
250
+ vault_client.namespace
195
251
  end
196
252
  log.debug "Vault read: path=#{full_path}, namespace=#{namespace}"
197
253
  end
198
254
 
199
255
  def connected_vault_client(cluster_name = nil)
200
256
  selected_cluster = selected_connected_cluster_name(cluster_name)
201
- raise ArgumentError, "Vault cluster not connected: #{cluster_name}" if selected_cluster.nil? && cluster_name
257
+ raise ArgumentError, "Vault cluster not connected: #{cluster_name || default_cluster_name}" if selected_cluster.nil?
202
258
 
203
- selected_cluster ? vault_client(selected_cluster) : nil
259
+ vault_client(selected_cluster)
204
260
  end
205
261
 
206
262
  def unwrap_kv_v2(data, full_path)
@@ -210,32 +266,6 @@ module Legion
210
266
  data[:data]
211
267
  end
212
268
 
213
- def resolve_ssl_verify(tls_config)
214
- return true if tls_config.nil?
215
-
216
- verify = tls_config[:verify]&.to_s
217
- verify != 'none'
218
- end
219
-
220
- def resolve_vault_address(vault_settings)
221
- protocol = vault_settings[:protocol] || 'http'
222
- address = vault_settings[:address] || 'localhost'
223
- port = vault_settings[:port] || 8200
224
-
225
- if address.match?(%r{\Ahttps?://})
226
- uri = URI.parse(address)
227
- protocol = uri.scheme
228
- address = uri.host
229
- port = uri.port if vault_settings[:port].nil?
230
- end
231
-
232
- "#{protocol}://#{address}:#{port}"
233
- end
234
-
235
- def log_vault_connection_error(error)
236
- handle_exception(error, level: :error, operation: 'crypt.vault.connect_vault', address: ::Vault.address)
237
- end
238
-
239
269
  def log_vault_debug(message)
240
270
  log.debug(message)
241
271
  end
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'legion/json'
5
+ require 'openssl'
6
+ require 'uri'
7
+
8
+ module Legion
9
+ module Crypt
10
+ module Vault
11
+ class HTTPError < StandardError
12
+ attr_reader :code, :errors, :address
13
+
14
+ def initialize(code:, errors: [], address: nil, **)
15
+ @code = Integer(code)
16
+ @errors = Array(errors)
17
+ @address = address
18
+ super("Vault HTTP #{@code}#{": #{@errors.join('; ')}" unless @errors.empty?}")
19
+ end
20
+ end
21
+
22
+ class HTTPClientError < HTTPError; end
23
+ class HTTPServerError < HTTPError; end
24
+ end
25
+
26
+ class VaultClient
27
+ CONNECTION_ATTRIBUTES = %i[address ssl_verify ca cert key timeout].freeze
28
+
29
+ attr_reader(*CONNECTION_ATTRIBUTES, :logical, :sys, :auth_token, :token)
30
+ attr_accessor :namespace
31
+
32
+ def initialize(address:, token: nil, namespace: nil, tls: {}, timeout: 5, **)
33
+ @address = validate_address(address: address)
34
+ @timeout = validate_timeout(timeout: timeout)
35
+ @token = token
36
+ @namespace = namespace
37
+ @ssl_verify = tls[:verify] != false && tls[:verify].to_s != 'none'
38
+ @ca = tls[:ca]
39
+ @cert = tls[:cert]
40
+ @key = tls[:key]
41
+ @mutex = Mutex.new
42
+ @retired = false
43
+ @logical = Logical.new(client: self)
44
+ @sys = Sys.new(client: self)
45
+ @auth_token = AuthToken.new(client: self)
46
+ end
47
+
48
+ CONNECTION_ATTRIBUTES.each do |attribute|
49
+ define_method(:"#{attribute}=") do |value|
50
+ value = validate_address(address: value) if attribute == :address
51
+ value = validate_timeout(timeout: value) if attribute == :timeout
52
+ @mutex.synchronize do
53
+ return value if instance_variable_get(:"@#{attribute}") == value
54
+
55
+ previous = @connection
56
+ @connection = nil
57
+ instance_variable_set(:"@#{attribute}", value)
58
+ previous&.close
59
+ end
60
+ value
61
+ end
62
+ end
63
+
64
+ def token=(value)
65
+ @mutex.synchronize do
66
+ raise IOError, 'Vault client has been shut down' if @retired
67
+
68
+ @token = value
69
+ end
70
+ end
71
+
72
+ def kv(mount:, **)
73
+ KV.new(client: self, mount: mount)
74
+ end
75
+
76
+ def put(path:, body:, headers: {}, **)
77
+ request(method: :put, path: path, body: body, headers: headers) || {}
78
+ end
79
+
80
+ def request(method:, path:, body: nil, headers: {}, nil_on_not_found: false, **)
81
+ connection, request_headers, address = @mutex.synchronize do
82
+ raise IOError, 'Vault client has been shut down' if @retired
83
+
84
+ [@connection ||= build_connection, vault_headers(headers: headers), @address]
85
+ end
86
+ payload = body.is_a?(String) || body.nil? ? body : Legion::JSON.dump(body)
87
+ response = connection.run_request(method, request_path(path: path), payload, request_headers)
88
+ return nil if nil_on_not_found && response.status == 404
89
+
90
+ decode_response(response: response, address: address)
91
+ end
92
+
93
+ def shutdown
94
+ @mutex.synchronize do
95
+ @retired = true
96
+ previous = @connection
97
+ @connection = nil
98
+ previous&.close
99
+ end
100
+ nil
101
+ end
102
+
103
+ def inspect
104
+ "#<#{self.class}>"
105
+ end
106
+
107
+ class Logical
108
+ def initialize(client:, **)
109
+ @client = client
110
+ end
111
+
112
+ def read(path:, **)
113
+ Secret.decode(data: @client.request(method: :get, path: path, nil_on_not_found: true))
114
+ end
115
+
116
+ def write(path:, data:, **)
117
+ Secret.decode(data: @client.put(path: path, body: data))
118
+ end
119
+
120
+ def delete(path:, **)
121
+ @client.request(method: :delete, path: path)
122
+ true
123
+ end
124
+ end
125
+
126
+ class Sys
127
+ class HealthStatus
128
+ def initialize(data:, **)
129
+ @initialized = data[:initialized]
130
+ end
131
+
132
+ def initialized?
133
+ @initialized == true
134
+ end
135
+ end
136
+
137
+ def initialize(client:, **)
138
+ @client = client
139
+ end
140
+
141
+ def health_status
142
+ HealthStatus.new(data: @client.request(method: :get, path: 'sys/health'))
143
+ end
144
+
145
+ def mounts
146
+ @client.request(method: :get, path: 'sys/mounts').fetch(:data)
147
+ end
148
+
149
+ def renew(lease_id:, **)
150
+ Secret.decode(data: @client.put(path: 'sys/leases/renew', body: { lease_id: lease_id }))
151
+ end
152
+
153
+ def revoke(lease_id:, **)
154
+ @client.put(path: 'sys/leases/revoke', body: { lease_id: lease_id })
155
+ true
156
+ end
157
+ end
158
+
159
+ class AuthToken
160
+ def initialize(client:, **)
161
+ @client = client
162
+ end
163
+
164
+ def renew_self
165
+ Secret.decode(data: @client.put(path: 'auth/token/renew-self', body: {}))
166
+ end
167
+
168
+ def revoke_self
169
+ @client.put(path: 'auth/token/revoke-self', body: {})
170
+ true
171
+ end
172
+ end
173
+
174
+ class KV
175
+ def initialize(client:, mount:, **)
176
+ @client = client
177
+ mount = mount.to_s
178
+ first = mount.index(%r{[^/]})
179
+ @mount = first ? mount[first..mount.rindex(%r{[^/]})] : ''
180
+ end
181
+
182
+ def read(path:, **)
183
+ response = @client.request(method: :get, path: "#{@mount}/data/#{path}", nil_on_not_found: true)
184
+ return nil if response.nil?
185
+
186
+ Secret.decode(data: response.merge(data: response.dig(:data, :data)))
187
+ end
188
+
189
+ def write(path:, data:, **)
190
+ Secret.decode(data: @client.put(path: "#{@mount}/data/#{path}", body: { data: data }))
191
+ end
192
+
193
+ def read_metadata(path:, **)
194
+ Secret.decode(data: @client.request(method: :get, path: "#{@mount}/metadata/#{path}", nil_on_not_found: true))
195
+ end
196
+ end
197
+
198
+ class Secret
199
+ attr_reader :data, :auth, :lease_id, :lease_duration
200
+
201
+ def self.decode(data:, **)
202
+ return nil if data.nil? || data.empty?
203
+
204
+ new(data: data)
205
+ end
206
+
207
+ def initialize(data:, **)
208
+ @data = data[:data]
209
+ @auth = Auth.new(data: data[:auth]) unless data[:auth].nil?
210
+ @lease_id = data[:lease_id]
211
+ @lease_duration = data[:lease_duration]
212
+ @renewable = data[:renewable]
213
+ end
214
+
215
+ def renewable?
216
+ @renewable == true
217
+ end
218
+
219
+ def inspect
220
+ "#<#{self.class}>"
221
+ end
222
+ end
223
+
224
+ class Auth
225
+ attr_reader :client_token, :lease_duration, :policies, :metadata
226
+
227
+ def initialize(data:, **)
228
+ @client_token = data[:client_token]
229
+ @lease_duration = data[:lease_duration]
230
+ @policies = data[:policies]
231
+ @metadata = data[:metadata]
232
+ @renewable = data[:renewable]
233
+ end
234
+
235
+ def renewable?
236
+ @renewable == true
237
+ end
238
+
239
+ def inspect
240
+ "#<#{self.class}>"
241
+ end
242
+ end
243
+
244
+ private
245
+
246
+ def validate_address(address:, **)
247
+ uri = URI.parse(address.to_s)
248
+ unless uri.is_a?(URI::HTTP) && uri.host && !uri.userinfo && !uri.query && !uri.fragment
249
+ raise ArgumentError, 'Vault address must be an HTTP(S) URL without credentials, query, or fragment'
250
+ end
251
+
252
+ uri.to_s.freeze
253
+ rescue URI::InvalidURIError
254
+ raise ArgumentError, 'Vault address must be a valid HTTP(S) URL'
255
+ end
256
+
257
+ def validate_timeout(timeout:, **)
258
+ seconds = Float(timeout)
259
+ raise ArgumentError, 'Vault timeout must be positive and finite' unless seconds.positive? && seconds.finite?
260
+
261
+ seconds
262
+ end
263
+
264
+ def vault_headers(headers:, **)
265
+ result = Faraday::Utils::Headers.new('Accept' => 'application/json', 'Content-Type' => 'application/json')
266
+ result.update(headers)
267
+ result.delete('X-Vault-Token')
268
+ result.delete('X-Vault-Namespace')
269
+ result['X-Vault-Token'] = @token unless @token.nil?
270
+ result['X-Vault-Namespace'] = @namespace unless @namespace.nil?
271
+ result
272
+ end
273
+
274
+ def request_path(path:, **)
275
+ relative = path.to_s.sub(%r{\A/v1(?:/|\z)}, '').sub(%r{\A/+}, '')
276
+ encoded = relative.split('/', -1).map do |segment|
277
+ if ['.', '..'].include?(segment)
278
+ segment.gsub('.', '%2E')
279
+ else
280
+ URI.encode_www_form_component(segment).gsub('+', '%20')
281
+ end
282
+ end
283
+ "/v1/#{encoded.join('/')}"
284
+ end
285
+
286
+ def build_connection
287
+ Faraday.new(url: @address, ssl: tls_options) do |connection|
288
+ connection.options.timeout = @timeout
289
+ connection.options.open_timeout = @timeout
290
+ connection.options.read_timeout = @timeout
291
+ connection.options.write_timeout = @timeout
292
+ connection.adapter :net_http
293
+ end
294
+ end
295
+
296
+ def tls_options
297
+ options = { verify: @ssl_verify != false && @ssl_verify.to_s != 'none' }
298
+ if @ca
299
+ if @ca.include?('-----BEGIN')
300
+ store = OpenSSL::X509::Store.new
301
+ certificates = @ca.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m)
302
+ raise ArgumentError, 'Vault CA PEM contains no certificates' if certificates.empty?
303
+
304
+ certificates.each { |certificate| store.add_cert(OpenSSL::X509::Certificate.new(certificate)) }
305
+ options[:cert_store] = store
306
+ else
307
+ options[:ca_file] = @ca
308
+ end
309
+ end
310
+ options[:client_cert] = tls_certificate(certificate: @cert) if @cert
311
+ options[:client_key] = tls_key(key: @key) if @key
312
+ options
313
+ end
314
+
315
+ def tls_certificate(certificate:, **)
316
+ return certificate if certificate.is_a?(OpenSSL::X509::Certificate)
317
+
318
+ pem = certificate.include?('-----BEGIN') ? certificate : File.read(certificate)
319
+ certificates = pem.scan(/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m)
320
+ return OpenSSL::X509::Certificate.new(pem) if certificates.length < 2
321
+
322
+ certificates.map { |entry| OpenSSL::X509::Certificate.new(entry) }
323
+ end
324
+
325
+ def tls_key(key:, **)
326
+ return key if key.is_a?(OpenSSL::PKey::PKey)
327
+
328
+ OpenSSL::PKey.read(key.include?('-----BEGIN') ? key : File.read(key))
329
+ end
330
+
331
+ def decode_response(response:, address:, **)
332
+ status = response.status
333
+ unless (200..299).cover?(status)
334
+ error_class = if (400..499).cover?(status)
335
+ Legion::Crypt::Vault::HTTPClientError
336
+ elsif (500..599).cover?(status)
337
+ Legion::Crypt::Vault::HTTPServerError
338
+ else
339
+ Legion::Crypt::Vault::HTTPError
340
+ end
341
+ begin
342
+ decoded = response.body.nil? || response.body.empty? ? {} : Legion::JSON.load(response.body)
343
+ rescue Legion::JSON::ParseError
344
+ raise error_class.new(code: status, errors: [response.body.to_s], address: address)
345
+ end
346
+ errors = decoded.is_a?(Hash) ? decoded[:errors] : [response.body.to_s]
347
+ raise error_class.new(code: status, errors: errors, address: address)
348
+ end
349
+ return nil if status == 204 || response.body.nil? || response.body.empty?
350
+
351
+ Legion::JSON.load(response.body)
352
+ end
353
+ end
354
+ end
355
+ end