legion-crypt 1.5.12 → 1.5.37

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,19 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'securerandom'
4
+ require 'digest'
5
+ require 'timeout'
4
6
  require 'legion/logging/helper'
5
7
 
6
8
  module Legion
7
9
  module Crypt
8
10
  module ClusterSecret
11
+ class InitializationError < StandardError; end
12
+
9
13
  include Legion::Logging::Helper
10
14
 
11
15
  def find_cluster_secret
12
- %i[from_settings from_vault from_transport generate_secure_random].each do |method|
16
+ secret_length
17
+ cluster_secret_timeout
18
+ raise InitializationError, 'Cluster secret initialization cancelled during shutdown' if Legion::Settings[:client]&.dig(:shutting_down)
19
+
20
+ %i[from_settings from_vault from_transport].each do |method|
21
+ break if method == :from_transport && only_member? == true
22
+
13
23
  result = send(method)
14
24
  next if result.nil?
15
25
 
16
26
  unless validate_hex(result)
27
+ raise InitializationError, 'Configured cluster secret is not valid hexadecimal key material' if method == :from_settings
28
+
17
29
  log.warn("Legion::Crypt.#{method} gave a value but it isn't a valid hex")
18
30
  next
19
31
  end
@@ -21,7 +33,7 @@ module Legion
21
33
  set_cluster_secret(result, method != :from_vault)
22
34
  return result
23
35
  end
24
- return unless only_member?
36
+ return unless only_member? == true
25
37
 
26
38
  key = generate_secure_random
27
39
  set_cluster_secret(key)
@@ -50,26 +62,36 @@ module Legion
50
62
  alias cluster_secret from_settings
51
63
 
52
64
  def from_transport
53
- return nil unless Legion::Settings[:transport][:connected]
65
+ return nil unless Legion::Settings[:transport]&.dig(:connected)
66
+ return nil if defined?(Legion::Transport::TYPE) && Legion::Transport::TYPE == 'local'
54
67
 
55
68
  require 'legion/transport/messages/request_cluster_secret'
56
69
  log.info 'Requesting cluster secret via public key'
57
70
  log.warn 'cluster_secret already set but we are requesting a new value' unless from_settings.nil?
58
- start = Time.now
71
+ start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
72
+ deadline = start + cluster_secret_timeout
59
73
  Legion::Transport::Messages::RequestClusterSecret.new.publish
60
74
  sleep_time = 0.001
61
- until !Legion::Settings[:crypt][:cluster_secret].nil? || (Time.now - start) > cluster_secret_timeout
62
- sleep(sleep_time)
63
- sleep_time *= 2 unless sleep_time > 0.5
75
+ while from_settings.nil?
76
+ raise InitializationError, 'Cluster secret initialization cancelled during shutdown' if Legion::Settings[:client]&.dig(:shutting_down)
77
+
78
+ remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
79
+ break unless remaining.positive?
80
+
81
+ sleep([sleep_time, remaining].min)
82
+ sleep_time = [sleep_time * 2, 0.5].min
64
83
  end
65
84
 
66
85
  unless from_settings.nil?
67
- log.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms"
86
+ elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start
87
+ log.info "Received cluster secret in #{(elapsed * 1000.0).round}ms"
68
88
  return from_settings
69
89
  end
70
90
 
71
91
  log.error 'Cluster secret is still unknown!'
72
92
  nil
93
+ rescue InitializationError
94
+ raise
73
95
  rescue StandardError => e
74
96
  handle_exception(e, level: :error, operation: 'crypt.cluster_secret.from_transport')
75
97
  nil
@@ -87,7 +109,16 @@ module Legion
87
109
  end
88
110
 
89
111
  def only_member?
90
- Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero?
112
+ return true if defined?(Legion::Transport::TYPE) && Legion::Transport::TYPE == 'local'
113
+
114
+ transport = Legion::Settings[:transport]
115
+ return Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero? if transport&.dig(:connected)
116
+
117
+ return true unless defined?(Legion::Transport)
118
+ return true if transport&.dig(:enabled) == false
119
+ return true if defined?(Legion::Mode) ? Legion::Mode.lite? : ENV['LEGION_MODE'] == 'lite'
120
+
121
+ nil
91
122
  rescue StandardError => e
92
123
  handle_exception(e, level: :warn, operation: 'crypt.cluster_secret.only_member?')
93
124
  nil
@@ -96,8 +127,8 @@ module Legion
96
127
  def set_cluster_secret(value, push_to_vault = true) # rubocop:disable Style/OptionalBooleanParameter
97
128
  raise TypeError unless validate_hex(value)
98
129
 
99
- Legion::Settings[:crypt][:cluster_secret] = value
100
- @cs = nil
130
+ Legion::Settings[:crypt][:cluster_secret] = value.dup.freeze
131
+ @cluster_secret_cache = nil
101
132
  Legion::Settings[:crypt][:cs_encrypt_ready] = true
102
133
  push_cs_to_vault if push_to_vault && settings_push_vault
103
134
  log.info "Cluster secret loaded into settings push_to_vault=#{push_to_vault}"
@@ -114,11 +145,19 @@ module Legion
114
145
  end
115
146
 
116
147
  def cluster_secret_timeout
117
- Legion::Settings[:crypt][:cluster_secret_timeout] || 5
148
+ timeout = Legion::Settings[:crypt].fetch(:cluster_secret_timeout, 5)
149
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout.positive?
150
+ raise InitializationError, 'cluster_secret_timeout must be a finite positive number'
151
+ end
152
+
153
+ timeout
118
154
  end
119
155
 
120
156
  def secret_length
121
- Legion::Settings[:crypt][:cluster_length] || Legion::Settings[:crypt][:cluster_lenth] || 32
157
+ length = Legion::Settings[:crypt][:cluster_length] || Legion::Settings[:crypt][:cluster_lenth] || 32
158
+ raise InitializationError, 'cluster_length must be a positive integer' unless length.is_a?(Integer) && length.positive?
159
+
160
+ length
122
161
  end
123
162
 
124
163
  def generate_secure_random(length = secret_length)
@@ -126,10 +165,26 @@ module Legion
126
165
  end
127
166
 
128
167
  def cs
129
- @cs ||= Digest::SHA256.digest(find_cluster_secret)
168
+ secret_length
169
+ raise InitializationError, 'Cluster secret initialization cancelled during shutdown' if Legion::Settings[:client]&.dig(:shutting_down)
170
+
171
+ source = from_settings || Timeout.timeout(cluster_secret_timeout) { find_cluster_secret }
172
+ raise InitializationError, 'No validated cluster secret is available' unless validate_hex(source)
173
+
174
+ Legion::Settings[:crypt][:cs_encrypt_ready] = true
175
+ cached = @cluster_secret_cache
176
+ return cached[:key] if cached && cached[:secret] == source
177
+
178
+ source = source.dup.freeze
179
+ key = Digest::SHA256.digest(source)
180
+ @cluster_secret_cache = { secret: source, key: key }.freeze
181
+ key
130
182
  rescue StandardError => e
183
+ Legion::Settings[:crypt][:cs_encrypt_ready] = false
131
184
  handle_exception(e, level: :error, operation: 'crypt.cluster_secret.cs')
132
- nil
185
+ raise if e.is_a?(InitializationError)
186
+
187
+ raise InitializationError, 'Cluster secret initialization failed'
133
188
  end
134
189
 
135
190
  def validate_hex(value, length = secret_length)
@@ -137,10 +192,7 @@ module Legion
137
192
  return false if value.empty?
138
193
  return false unless value.match?(/\A\h+\z/)
139
194
 
140
- expected_length = length.to_i * 2
141
- return true if expected_length.zero?
142
-
143
- value.length == expected_length
195
+ length.is_a?(Integer) && length.positive? && value.length == length * 2
144
196
  end
145
197
 
146
198
  def cluster_secret_vault_path
@@ -12,11 +12,9 @@ module Legion
12
12
  key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
13
13
 
14
14
  log.info "[crypt] Erasing tenant #{tenant_id}"
15
- if Legion::Crypt.respond_to?(:delete)
16
- Legion::Crypt.delete(key_path)
17
- elsif defined?(Legion::Crypt::Vault)
18
- delete_vault_key(key_path)
19
- end
15
+ result = Legion::Crypt.delete(key_path)
16
+ raise(result[:error] || 'Vault key deletion failed') unless result[:success]
17
+
20
18
  Legion::Events.emit('crypt.tenant_erased', { tenant_id: tenant_id, erased_at: Time.now.utc }) if defined?(Legion::Events)
21
19
  log.warn "[crypt] Tenant #{tenant_id} cryptographically erased"
22
20
 
@@ -28,7 +26,6 @@ module Legion
28
26
 
29
27
  def verify_erasure(tenant_id:)
30
28
  key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
31
- raise 'Legion::Crypt.read is unavailable' unless Legion::Crypt.respond_to?(:read)
32
29
 
33
30
  data = Legion::Crypt.read(key_path, nil)
34
31
  erased = data.nil?
@@ -41,10 +38,6 @@ module Legion
41
38
 
42
39
  private
43
40
 
44
- def delete_vault_key(path)
45
- ::Vault.logical.delete(path)
46
- end
47
-
48
41
  def tenant_prefix
49
42
  begin
50
43
  Legion::Settings[:crypt][:partition_keys][:vault_tenant_prefix]
@@ -12,6 +12,7 @@ module Legion
12
12
  module Crypt
13
13
  module JwksClient
14
14
  CACHE_TTL = 3600
15
+ MISS_REFRESH_COOLDOWN = 30
15
16
 
16
17
  @cache = {}
17
18
  @cache_mutex = Mutex.new
@@ -22,39 +23,30 @@ module Legion
22
23
  include Legion::Logging::Helper
23
24
 
24
25
  def fetch_keys(jwks_url)
25
- with_url_lock(jwks_url) do
26
- log.debug "JWKS fetch: #{jwks_url}"
27
- response = http_get(jwks_url)
28
- jwks_data = parse_response(response)
29
- keys = parse_jwks(jwks_data)
30
-
31
- cache_write(jwks_url, keys)
32
- log.info "JWKS fetched url=#{jwks_url} keys=#{keys.size}"
33
- keys
34
- end
35
- rescue StandardError => e
36
- handle_exception(e, level: :warn, operation: 'crypt.jwks.fetch_keys', jwks_url: jwks_url)
37
- raise
26
+ with_url_lock(jwks_url) { refresh_keys(jwks_url: jwks_url) }
38
27
  end
39
28
 
40
29
  def find_key(jwks_url, kid)
41
30
  cached = cache_read(jwks_url)
31
+ return cached[:keys][kid] if cached && !expired?(cached[:fetched_at]) && cached[:keys][kid]
42
32
 
43
- if cached && !expired?(cached[:fetched_at])
44
- key = cached[:keys][kid]
45
- if key
46
- log.debug "JWKS cache hit: kid=#{kid}"
47
- return key
48
- end
33
+ with_url_lock(jwks_url) do
34
+ cached = cache_read(jwks_url)
35
+ fresh = cached && !expired?(cached[:fetched_at])
36
+ return cached[:keys][kid] if fresh && cached[:keys][kid]
49
37
 
50
- log.debug "JWKS cache miss for kid=#{kid}; refreshing keys"
51
- end
38
+ if cached && cached[:refresh_after] && (fresh || cached[:error]) &&
39
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < cached[:refresh_after]
40
+ raise cached[:error] if cached[:error]
41
+
42
+ raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
43
+ end
52
44
 
53
- keys = fetch_keys(jwks_url)
54
- key = keys[kid]
55
- return key if key
45
+ keys = refresh_keys(jwks_url: jwks_url, requested_kid: kid)
46
+ return keys[kid] if keys[kid]
56
47
 
57
- raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
48
+ raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
49
+ end
58
50
  end
59
51
 
60
52
  def prefetch!(jwks_url)
@@ -87,24 +79,44 @@ module Legion
87
79
  def clear_cache
88
80
  stop_background_refresh!
89
81
  @cache_mutex.synchronize { @cache = {} }
90
- @locks_mutex.synchronize { @locks = {} }
91
82
  log.info 'JWKS cache cleared'
92
83
  end
93
84
 
94
85
  private
95
86
 
87
+ def refresh_keys(jwks_url:, requested_kid: nil, **)
88
+ previous = cache_read(jwks_url)
89
+ refreshing_miss = requested_kid && previous && !expired?(previous[:fetched_at])
90
+ log.debug "JWKS fetch: #{jwks_url}"
91
+ keys = parse_jwks(parse_response(http_get(jwks_url)))
92
+ entry = { keys: keys, fetched_at: Time.now }
93
+ if requested_kid && (refreshing_miss || !keys.key?(requested_kid))
94
+ entry[:refresh_after] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + MISS_REFRESH_COOLDOWN
95
+ end
96
+ cache_write(jwks_url, entry)
97
+ log.info "JWKS fetched url=#{jwks_url} keys=#{keys.size}"
98
+ keys
99
+ rescue StandardError => e
100
+ if requested_kid
101
+ entry = (previous || { keys: {}, fetched_at: nil }).merge(
102
+ error: e, refresh_after: ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + MISS_REFRESH_COOLDOWN
103
+ )
104
+ cache_write(jwks_url, entry)
105
+ end
106
+ handle_exception(e, level: :warn, operation: 'crypt.jwks.fetch_keys', jwks_url: jwks_url)
107
+ raise
108
+ end
109
+
96
110
  def cache_read(jwks_url)
97
111
  @cache_mutex.synchronize { @cache[jwks_url] }
98
112
  end
99
113
 
100
- def cache_write(jwks_url, keys)
101
- @cache_mutex.synchronize do
102
- @cache[jwks_url] = { keys: keys, fetched_at: Time.now }
103
- end
114
+ def cache_write(jwks_url, entry)
115
+ @cache_mutex.synchronize { @cache[jwks_url] = entry }
104
116
  end
105
117
 
106
118
  def expired?(fetched_at)
107
- Time.now - fetched_at > CACHE_TTL
119
+ fetched_at.nil? || Time.now - fetched_at > CACHE_TTL
108
120
  end
109
121
 
110
122
  def http_get(url)
@@ -109,18 +109,16 @@ module Legion
109
109
  def self.verify_with_jwks(token, jwks_url:, **opts)
110
110
  header = decode_header(token)
111
111
  kid = header['kid']
112
- algorithm = header['alg'] || 'RS256'
112
+ algorithm = header['alg']
113
113
 
114
- raise InvalidTokenError, 'token header missing kid' unless kid
115
-
116
- validate_algorithm!(algorithm)
117
-
118
- public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid)
114
+ raise InvalidTokenError, 'token header missing or invalid kid' unless kid.is_a?(String) && !kid.empty?
115
+ raise InvalidTokenError, 'unsupported token algorithm' unless SUPPORTED_ALGORITHMS.include?(algorithm)
119
116
 
120
117
  verify_expiration = opts.fetch(:verify_expiration, true)
121
118
  issuers = opts[:issuers]
122
119
  audience = opts[:audience]
123
120
  validate_external_requirements!(issuers: issuers, audience: audience)
121
+ public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid)
124
122
 
125
123
  decode_opts = {
126
124
  algorithm: algorithm,
@@ -165,7 +163,10 @@ module Legion
165
163
  raise DecodeError, 'invalid token format' unless parts.size == 3
166
164
 
167
165
  header_json = Base64.urlsafe_decode64(parts[0])
168
- ::JSON.parse(header_json)
166
+ header = ::JSON.parse(header_json)
167
+ raise DecodeError, 'JWT header must be an object' unless header.is_a?(Hash)
168
+
169
+ header
169
170
  rescue ::JSON::ParserError, ArgumentError => e
170
171
  handle_exception(e, level: :warn, operation: 'crypt.jwt.decode_header')
171
172
  raise DecodeError, "failed to decode token header: #{e.message}"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_client'
4
5
 
5
6
  module Legion
6
7
  module Crypt
@@ -17,7 +18,7 @@ module Legion
17
18
  attr_reader :kerberos_principal
18
19
  end
19
20
 
20
- def self.login(vault_client:, service_principal:, auth_path: DEFAULT_AUTH_PATH)
21
+ def self.login(vault_client:, service_principal:, auth_path: DEFAULT_AUTH_PATH, principal_guard: nil)
21
22
  raise GemMissingError, 'lex-kerberos gem is required for Kerberos auth' unless spnego_available?
22
23
 
23
24
  log.info "KerberosAuth login requested auth_path=#{auth_path}"
@@ -26,12 +27,21 @@ module Legion
26
27
  ns = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a'
27
28
  log.debug("KerberosAuth: login: vault_client.address=#{addr}, namespace=#{ns}")
28
29
 
29
- @kerberos_principal = nil
30
+ if principal_guard
31
+ principal_guard.call { @kerberos_principal = nil }
32
+ else
33
+ @kerberos_principal = nil
34
+ end
30
35
  token = obtain_token(service_principal)
31
36
  log.debug("KerberosAuth: login: SPNEGO token obtained (#{token.length} chars)")
32
37
 
33
38
  result = exchange_token(vault_client, token, auth_path)
34
- @kerberos_principal = result[:metadata]&.dig('username') || result[:metadata]&.dig(:username)
39
+ principal = result[:metadata]&.dig('username') || result[:metadata]&.dig(:username)
40
+ if principal_guard
41
+ principal_guard.call { @kerberos_principal = principal }
42
+ else
43
+ @kerberos_principal = principal
44
+ end
35
45
  log.debug("KerberosAuth: login: authenticated as #{@kerberos_principal.inspect}, policies=#{result[:policies].inspect}")
36
46
  log.debug("KerberosAuth: login: renewable=#{result[:renewable]}, ttl=#{result[:lease_duration]}s")
37
47
  log.info "KerberosAuth login success principal=#{@kerberos_principal || 'unknown'} auth_path=#{auth_path}"
@@ -80,11 +90,11 @@ module Legion
80
90
  namespace = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a'
81
91
  log.debug("KerberosAuth: exchange_token: PUT /v1/#{auth_path} (namespace=#{namespace})")
82
92
  json = vault_client.put(
83
- "/v1/#{auth_path}",
84
- '{}',
85
- 'Authorization' => "Negotiate #{spnego_token}"
93
+ path: "/v1/#{auth_path}",
94
+ body: '{}',
95
+ headers: { 'Authorization' => "Negotiate #{spnego_token}" }
86
96
  )
87
- response = ::Vault::Secret.decode(json)
97
+ response = Legion::Crypt::VaultClient::Secret.decode(data: json)
88
98
  raise AuthError, 'Vault Kerberos auth returned no auth data' unless response&.auth
89
99
 
90
100
  auth = response.auth
@@ -95,7 +105,7 @@ module Legion
95
105
  policies: auth.policies,
96
106
  metadata: auth.metadata
97
107
  }
98
- rescue ::Vault::HTTPClientError => e
108
+ rescue Legion::Crypt::Vault::HTTPClientError => e
99
109
  handle_exception(e, level: :warn, operation: 'crypt.kerberos_auth.exchange_token', auth_path: auth_path)
100
110
  log.debug("KerberosAuth: exchange_token: HTTP error: #{e.message}")
101
111
  raise AuthError, "Vault Kerberos auth failed: #{e.message}"
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging/helper'
4
+ require 'legion/crypt/vault_operation'
4
5
 
5
6
  module Legion
6
7
  module Crypt
@@ -9,35 +10,35 @@ module Legion
9
10
 
10
11
  def ldap_login(cluster_name:, username:, password:)
11
12
  cluster_name = cluster_name.to_sym
13
+ generation = vault_auth_generation
14
+ config = clusters.fetch(cluster_name)
12
15
  log.info "LDAP login requested user=#{username} cluster=#{cluster_name}"
13
16
  client = vault_client(cluster_name)
14
- secret = client.logical.write("auth/ldap/login/#{username}", password: password)
17
+ secret = Legion::Crypt::VaultOperation.with_timeout do
18
+ client.logical.write(path: "auth/ldap/login/#{username}", data: { password: password })
19
+ end
15
20
  auth = secret.auth
16
- token = auth.client_token
17
21
 
18
- clusters[cluster_name][:token] = token
19
- clusters[cluster_name][:connected] = true
20
- client.token = token if client.respond_to?(:token=)
22
+ grant = { token: auth.client_token, lease_duration: auth.lease_duration, renewable: auth.renewable?, policies: auth.policies }
23
+ install_vault_grant(client: client, config: config, grant: grant, auth_method: 'ldap', generation: generation)
21
24
 
22
25
  log.info "LDAP login success: user=#{username}, cluster=#{cluster_name}"
23
- { token: token, lease_duration: auth.lease_duration,
24
- renewable: auth.renewable?, policies: auth.policies }
26
+ grant
25
27
  rescue StandardError => e
26
- handle_exception(e, level: :error, operation: 'crypt.ldap_auth.ldap_login', cluster_name: cluster_name, username: username)
27
- log.error "LDAP login failed: user=#{username}, cluster=#{cluster_name}: #{e.message}"
28
+ log.error "LDAP login failed: user=#{username}, cluster=#{cluster_name}, error=#{e.class}"
28
29
  raise
29
30
  end
30
31
 
31
32
  def ldap_login_all(username:, password:)
32
33
  results = {}
33
34
  clusters.each do |name, config|
34
- next unless config[:auth_method] == 'ldap'
35
+ next unless config[:auth_method].to_s == 'ldap'
35
36
 
36
37
  results[name] = ldap_login(cluster_name: name, username: username, password: password)
37
38
  rescue StandardError => e
38
- handle_exception(e, level: :warn, operation: 'crypt.ldap_auth.ldap_login_all', cluster_name: name, username: username)
39
- log.warn("Legion::Crypt::LdapAuth#ldap_login_all cluster=#{name} failed: #{e.message}")
40
- results[name] = { error: e.message }
39
+ log.warn("Legion::Crypt::LdapAuth#ldap_login_all cluster=#{name} failed: #{e.class}")
40
+ message = password.to_s.empty? ? e.message : e.message.gsub(password.to_s, '[REDACTED]')
41
+ results[name] = { error: message }
41
42
  end
42
43
  log.info "LDAP login_all complete successes=#{results.count { |_, result| result.is_a?(Hash) && !result.key?(:error) }} attempted=#{results.size}"
43
44
  results