legion-crypt 1.5.38 → 1.5.42

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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -3
  3. data/.rspec +2 -0
  4. data/AGENTS.md +2 -0
  5. data/CHANGELOG.md +28 -0
  6. data/CLAUDE.md +64 -192
  7. data/CODEOWNERS +4 -4
  8. data/Gemfile +4 -1
  9. data/README.md +60 -16
  10. data/legion-crypt.gemspec +1 -0
  11. data/lib/legion/crypt/cert_rotation.rb +7 -21
  12. data/lib/legion/crypt/cluster_secret.rb +3 -14
  13. data/lib/legion/crypt/ed25519.rb +2 -10
  14. data/lib/legion/crypt/erasure.rb +3 -13
  15. data/lib/legion/crypt/jwks_client.rb +4 -4
  16. data/lib/legion/crypt/kerberos_auth.rb +2 -2
  17. data/lib/legion/crypt/ldap_auth.rb +2 -2
  18. data/lib/legion/crypt/lease_manager.rb +7 -7
  19. data/lib/legion/crypt/mtls.rb +8 -46
  20. data/lib/legion/crypt/partition_keys.rb +2 -6
  21. data/lib/legion/crypt/settings.rb +24 -21
  22. data/lib/legion/crypt/spiffe/svid_rotation.rb +6 -9
  23. data/lib/legion/crypt/spiffe.rb +14 -39
  24. data/lib/legion/crypt/token_renewer.rb +6 -6
  25. data/lib/legion/crypt/vault/client.rb +355 -0
  26. data/lib/legion/crypt/vault/cluster.rb +330 -0
  27. data/lib/legion/crypt/vault/entity.rb +80 -0
  28. data/lib/legion/crypt/vault/jwt_auth.rb +99 -0
  29. data/lib/legion/crypt/vault/kerberos_auth.rb +53 -0
  30. data/lib/legion/crypt/vault/operation.rb +29 -0
  31. data/lib/legion/crypt/vault.rb +6 -11
  32. data/lib/legion/crypt/version.rb +1 -1
  33. data/lib/legion/crypt.rb +69 -80
  34. metadata +23 -8
  35. data/lib/legion/crypt/vault_client.rb +0 -355
  36. data/lib/legion/crypt/vault_cluster.rb +0 -331
  37. data/lib/legion/crypt/vault_entity.rb +0 -78
  38. data/lib/legion/crypt/vault_jwt_auth.rb +0 -97
  39. data/lib/legion/crypt/vault_kerberos_auth.rb +0 -51
  40. data/lib/legion/crypt/vault_operation.rb +0 -27
  41. /data/lib/legion/crypt/{vault_renewer.rb → vault/renewer.rb} +0 -0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-crypt
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.38
4
+ version: 1.5.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: 1.5.0
96
+ - !ruby/object:Gem::Dependency
97
+ name: legion-settings
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.4.2
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 1.4.2
96
110
  description: A gem used by the LegionIO framework for encryption
97
111
  email:
98
112
  - matthewdiverson@gmail.com
@@ -108,6 +122,7 @@ files:
108
122
  - ".github/workflows/ci.yml"
109
123
  - ".gitignore"
110
124
  - ".pre-commit-config.yaml"
125
+ - ".rspec"
111
126
  - ".rubocop.yml"
112
127
  - AGENTS.md
113
128
  - CHANGELOG.md
@@ -141,13 +156,13 @@ files:
141
156
  - lib/legion/crypt/tls.rb
142
157
  - lib/legion/crypt/token_renewer.rb
143
158
  - lib/legion/crypt/vault.rb
144
- - lib/legion/crypt/vault_client.rb
145
- - lib/legion/crypt/vault_cluster.rb
146
- - lib/legion/crypt/vault_entity.rb
147
- - lib/legion/crypt/vault_jwt_auth.rb
148
- - lib/legion/crypt/vault_kerberos_auth.rb
149
- - lib/legion/crypt/vault_operation.rb
150
- - lib/legion/crypt/vault_renewer.rb
159
+ - lib/legion/crypt/vault/client.rb
160
+ - lib/legion/crypt/vault/cluster.rb
161
+ - lib/legion/crypt/vault/entity.rb
162
+ - lib/legion/crypt/vault/jwt_auth.rb
163
+ - lib/legion/crypt/vault/kerberos_auth.rb
164
+ - lib/legion/crypt/vault/operation.rb
165
+ - lib/legion/crypt/vault/renewer.rb
151
166
  - lib/legion/crypt/version.rb
152
167
  - scripts/pre-commit-rubocop.sh
153
168
  - sonar-project.properties
@@ -1,355 +0,0 @@
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