legion-crypt 1.5.9 → 1.5.12

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: 42200f56d577b407725621c56af4f7d191c1ad0219c33c99f5e4adc02568aac6
4
- data.tar.gz: c231674d541726ab2fa3cf381e655a67be065e2481ce4f50c39d42b52875f6fa
3
+ metadata.gz: a4af81549d8e946acfc92ec4036496fce4af3d1be6fc10cba534298434f0c94a
4
+ data.tar.gz: 7e70028fa24e9760ff791f49f88817b37c9ee009b21fdc7dae9382a49306e8ca
5
5
  SHA512:
6
- metadata.gz: 2ee34bea6a9a73af47707259d8b857dfc1c1eb8f9e3519ccbefd811bb43e182c734795d15732de82058acb1101f3ba14bc16c39feeae87873c290e2aad58c049
7
- data.tar.gz: b4d4c72a242b0cfb229dcb27f654f41e6dfd6c3035c73901345588639ea102969e83edb31e5b9572705f674eeba8bd3a6fe89915b9db4ca2eedd33749c12fcf3
6
+ metadata.gz: 96269db0a9859015418df236468d0eff5b08490e1eb7bc55c7507199dda579924273979405d9e724b3d1237b7855ecc7b949fabdbc15dc378994ad19930112c9
7
+ data.tar.gz: b112f3eb47b538549e842eaa8a766e15c1934650f010082ec892a4b05fa995746dcff25ffeed90a2e625e09ee4d38d21a9d399d9787c6fd6dd1598f6895fc28f
data/.gitignore CHANGED
@@ -9,6 +9,7 @@
9
9
  /tmp/
10
10
  /legion/.idea/
11
11
  /.idea/
12
+ *.gem
12
13
  *.key
13
14
  # rspec failure tracking
14
15
  .rspec_status
@@ -0,0 +1,29 @@
1
+ # Standard LegionIO pre-commit configuration
2
+ # Install: pre-commit install
3
+ # Manual: pre-commit run --all-files
4
+ repos:
5
+ - repo: https://github.com/pre-commit/pre-commit-hooks
6
+ rev: v5.0.0
7
+ hooks:
8
+ - id: trailing-whitespace
9
+ - id: end-of-file-fixer
10
+ - id: check-yaml
11
+ - id: check-json
12
+ exclude: Gemfile\.lock
13
+ - id: check-merge-conflict
14
+
15
+ - repo: local
16
+ hooks:
17
+ - id: rubocop
18
+ name: RuboCop (autofix)
19
+ entry: scripts/pre-commit-rubocop.sh
20
+ language: script
21
+ types: [ruby]
22
+ pass_filenames: true
23
+
24
+ - id: ruby-syntax
25
+ name: Ruby syntax check
26
+ entry: ruby -c
27
+ language: system
28
+ types: [ruby]
29
+ pass_filenames: true
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.5.12] - 2026-04-27
6
+
7
+ ### Fixed
8
+ - `LeaseManager#trigger_reconnect` for `:postgresql` now calls `Legion::Data::Connection.reconnect_with_fresh_creds` (legion-data >= 1.6.26) instead of `sequel.disconnect` + `sequel.test_connection` — Sequel bakes credentials into the pool at `Sequel.connect` time, so the old approach reused stale credentials after Vault lease rotation, causing Apollo and other DB-backed services to silently lose access to data
9
+ - Fallback to legacy `disconnect`/`test_connection` path when `reconnect_with_fresh_creds` is not available, with explicit warning about potential stale credentials
10
+ - Reconnect failures now log at `:error` level (was `:warn`) since a failed reconnect means Apollo and DB-backed services are unavailable until the next rotation cycle
11
+ - Lease shutdown, logging fallback, and SPIFFE socket cleanup paths now emit warnings/debug logs instead of silently swallowing unexpected failures.
12
+
13
+ ## [1.5.11] - 2026-04-27
14
+
15
+ ### Fixed
16
+ - Cipher decrypt now validates malformed authenticated, legacy, and keypair ciphertext inputs before Base64/OpenSSL decoding, raising actionable errors that identify missing non-secret fields such as auth tag, IV, or cluster secret instead of generic `unpack1` nil failures.
17
+ - Crypt's logging compatibility helper now preserves full exception backtraces instead of truncating fallback log output to 10 frames.
18
+
19
+ ## [1.5.10] - 2026-04-19
20
+
21
+ ### Fixed
22
+ - `handle_exception` now passes the caller's `level:` kwarg through to `Legion::Logging.log_exception` instead of always defaulting to `:error` — optional missing-gem `LoadError`s log at the intended level (e.g. `:debug`). Fixes LegionIO/LegionIO#155
23
+ - `exception_log_message` now suppresses backtrace for `:debug` level — previously only suppressed when the backtrace was empty
24
+
5
25
  ## [1.5.9] - 2026-04-10
6
26
 
7
27
  ### Fixed
@@ -113,6 +113,13 @@ module Legion
113
113
 
114
114
  def decrypt_authenticated(message, init_vector, secret)
115
115
  _, encoded_ciphertext, encoded_auth_tag = message.split(':', 3)
116
+ validate_authenticated_ciphertext!(
117
+ encoded_ciphertext: encoded_ciphertext,
118
+ encoded_auth_tag: encoded_auth_tag,
119
+ init_vector: init_vector,
120
+ secret: secret,
121
+ message: message
122
+ )
116
123
 
117
124
  decipher = OpenSSL::Cipher.new(AUTHENTICATED_CIPHER)
118
125
  decipher.decrypt
@@ -123,6 +130,8 @@ module Legion
123
130
  end
124
131
 
125
132
  def decrypt_legacy(message, init_vector, secret)
133
+ validate_legacy_ciphertext!(message: message, init_vector: init_vector, secret: secret)
134
+
126
135
  decipher = OpenSSL::Cipher.new(LEGACY_CIPHER)
127
136
  decipher.decrypt
128
137
  decipher.key = secret
@@ -136,12 +145,70 @@ module Legion
136
145
 
137
146
  def decrypt_oaep_from_keypair(message)
138
147
  _, encoded_message = message.split(':', 2)
148
+ validate_keypair_ciphertext!(encoded_message: encoded_message, message: message, scheme: RSA_OAEP_PREFIX)
149
+
139
150
  private_key.private_decrypt(Base64.strict_decode64(encoded_message), RSA_OAEP_PADDING)
140
151
  end
141
152
 
142
153
  def decrypt_legacy_from_keypair(message)
154
+ validate_keypair_ciphertext!(encoded_message: message, message: message, scheme: 'legacy')
155
+
143
156
  private_key.private_decrypt(Base64.decode64(message), RSA_LEGACY_PADDING)
144
157
  end
158
+
159
+ def validate_authenticated_ciphertext!(encoded_ciphertext:, encoded_auth_tag:, init_vector:, secret:, message:)
160
+ missing = []
161
+ missing << 'ciphertext' if blank?(encoded_ciphertext)
162
+ missing << 'auth_tag' if blank?(encoded_auth_tag)
163
+ missing << 'iv' if blank?(init_vector)
164
+ missing << 'cluster_secret' if blank?(secret)
165
+ return if missing.empty?
166
+
167
+ raise ArgumentError, 'invalid authenticated ciphertext: missing ' \
168
+ "#{missing.join(', ')} " \
169
+ "(scheme=#{AUTHENTICATED_PREFIX} " \
170
+ "message_bytes=#{byte_size(message)} " \
171
+ "ciphertext_present=#{present?(encoded_ciphertext)} " \
172
+ "auth_tag_present=#{present?(encoded_auth_tag)} " \
173
+ "iv_present=#{present?(init_vector)} " \
174
+ "cluster_secret_present=#{present?(secret)})"
175
+ end
176
+
177
+ def validate_legacy_ciphertext!(message:, init_vector:, secret:)
178
+ missing = []
179
+ missing << 'ciphertext' if blank?(message)
180
+ missing << 'iv' if blank?(init_vector)
181
+ missing << 'cluster_secret' if blank?(secret)
182
+ return if missing.empty?
183
+
184
+ raise ArgumentError, 'invalid legacy ciphertext: missing ' \
185
+ "#{missing.join(', ')} " \
186
+ "(message_bytes=#{byte_size(message)} " \
187
+ "iv_present=#{present?(init_vector)} " \
188
+ "cluster_secret_present=#{present?(secret)})"
189
+ end
190
+
191
+ def validate_keypair_ciphertext!(encoded_message:, message:, scheme:)
192
+ return unless blank?(encoded_message)
193
+
194
+ raise ArgumentError, 'invalid keypair ciphertext: missing ciphertext ' \
195
+ "(scheme=#{scheme} message_bytes=#{byte_size(message)})"
196
+ end
197
+
198
+ def blank?(value)
199
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
200
+ end
201
+
202
+ def present?(value)
203
+ !blank?(value)
204
+ end
205
+
206
+ def byte_size(value)
207
+ return 0 if value.nil?
208
+ return value.bytesize if value.respond_to?(:bytesize)
209
+
210
+ value.to_s.bytesize
211
+ end
145
212
  end
146
213
  end
147
214
  end
@@ -248,10 +248,10 @@ module Legion
248
248
  next if @state_mutex.synchronize { @active_leases.empty? }
249
249
 
250
250
  Timeout.timeout(10) { shutdown }
251
- rescue Timeout::Error
252
- warn '[LeaseManager] at_exit shutdown timed out after 10s'
253
- rescue StandardError # best effort on crash
254
- nil
251
+ rescue Timeout::Error => e
252
+ log.warn("[LeaseManager] at_exit shutdown timed out after 10s: #{e.message}")
253
+ rescue StandardError => e # best effort on crash
254
+ log.warn("[LeaseManager] at_exit shutdown failed: #{e.class}: #{e.message}")
255
255
  end
256
256
  @at_exit_registered = true
257
257
  end
@@ -466,11 +466,7 @@ module Legion
466
466
  Legion::Transport::Connection.force_reconnect
467
467
  log.info("LeaseManager: triggered transport reconnect after '#{name}' reissue")
468
468
  when :postgresql
469
- return unless defined?(Legion::Data::Connection) && Legion::Data::Connection.sequel
470
-
471
- Legion::Data::Connection.sequel.disconnect
472
- Legion::Data::Connection.sequel.test_connection
473
- log.info("LeaseManager: triggered data pool reconnect after '#{name}' reissue")
469
+ trigger_postgresql_reconnect(name)
474
470
  when :redis
475
471
  return unless defined?(Legion::Cache)
476
472
 
@@ -482,8 +478,34 @@ module Legion
482
478
  log.info("LeaseManager: triggered cache reconnect after '#{name}' reissue")
483
479
  end
484
480
  rescue StandardError => e
485
- handle_exception(e, level: :warn, operation: 'crypt.lease_manager.trigger_reconnect', lease_name: name)
486
- log.warn("LeaseManager: reconnect for '#{name}' failed: #{e.message}")
481
+ handle_exception(e, level: :error, operation: 'crypt.lease_manager.trigger_reconnect', lease_name: name)
482
+ log.error("LeaseManager: reconnect for '#{name}' FAILED: #{e.message}" \
483
+ 'services may be unavailable until the next lease rotation')
484
+ end
485
+
486
+ def trigger_postgresql_reconnect(name)
487
+ unless defined?(Legion::Data::Connection)
488
+ log.debug("LeaseManager: no Legion::Data::Connection loaded for '#{name}' reconnect")
489
+ return
490
+ end
491
+
492
+ if Legion::Data::Connection.respond_to?(:reconnect_with_fresh_creds)
493
+ success = Legion::Data::Connection.reconnect_with_fresh_creds
494
+ if success
495
+ log.info("LeaseManager: reconnected data layer with fresh credentials after '#{name}' reissue")
496
+ else
497
+ log.error("LeaseManager: FAILED to reconnect data layer after '#{name}' reissue — " \
498
+ 'Apollo and other DB-backed services may be unavailable')
499
+ end
500
+ elsif Legion::Data::Connection.respond_to?(:sequel) && Legion::Data::Connection.sequel
501
+ log.warn('LeaseManager: legion-data does not support reconnect_with_fresh_creds — ' \
502
+ 'falling back to pool disconnect (may use stale credentials)')
503
+ Legion::Data::Connection.sequel.disconnect
504
+ Legion::Data::Connection.sequel.test_connection
505
+ log.info("LeaseManager: triggered data pool reconnect after '#{name}' reissue (legacy path)")
506
+ else
507
+ log.warn("LeaseManager: no active data connection to reconnect after '#{name}' reissue")
508
+ end
487
509
  end
488
510
 
489
511
  def running?
@@ -104,10 +104,18 @@ module Legion
104
104
  send_grpc_request(sock, method_path, request_body)
105
105
  read_grpc_response(sock)
106
106
  ensure
107
- sock.close rescue nil # rubocop:disable Style/RescueModifier
107
+ close_workload_api_socket(sock, method_path)
108
108
  end
109
109
  end
110
110
 
111
+ def close_workload_api_socket(sock, method_path)
112
+ sock.close
113
+ rescue StandardError => e
114
+ handle_exception(e, level: :debug, operation: 'crypt.spiffe.workload_api_client.close_socket',
115
+ method_path: method_path, socket_path: @socket_path)
116
+ nil
117
+ end
118
+
111
119
  def connect_socket
112
120
  raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" unless ::File.exist?(@socket_path)
113
121
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.5.9'
5
+ VERSION = '1.5.12'
6
6
  end
7
7
  end
@@ -6,8 +6,9 @@ begin
6
6
  'lib/legion/logging/helper.rb'
7
7
  )
8
8
  require helper_path if File.exist?(helper_path)
9
- rescue Gem::LoadError
10
- nil
9
+ rescue Gem::LoadError => e
10
+ require 'legion/logging'
11
+ Legion::Logging.warn("legion-crypt logging helper fallback active: #{e.message}")
11
12
  end
12
13
 
13
14
  require 'legion/logging'
@@ -38,7 +39,8 @@ module Legion
38
39
  return false unless Legion.const_defined?('Logging')
39
40
 
40
41
  Legion::Logging.respond_to?(level)
41
- rescue StandardError
42
+ rescue StandardError => e
43
+ Legion::Logging.warn("legion-crypt logging support check failed: #{e.message}")
42
44
  false
43
45
  end
44
46
  end
@@ -52,7 +54,7 @@ module Legion
52
54
  message = exception_log_message(exception, level: level, **opts) # rubocop:disable Style/ArgumentsForwarding
53
55
 
54
56
  if logging_supports?(:log_exception)
55
- Legion::Logging.log_exception(exception, lex: 'crypt', component_type: :helper)
57
+ Legion::Logging.log_exception(exception, level: level, lex: 'crypt', component_type: :helper)
56
58
  return
57
59
  end
58
60
  if logging_supports?(level)
@@ -77,7 +79,8 @@ module Legion
77
79
  return false unless Legion.const_defined?('Logging')
78
80
 
79
81
  Legion::Logging.respond_to?(level)
80
- rescue StandardError
82
+ rescue StandardError => e
83
+ Legion::Logging.warn("legion-crypt logging support check failed: #{e.message}")
81
84
  false
82
85
  end
83
86
 
@@ -86,9 +89,9 @@ module Legion
86
89
  prefix = operation ? "#{operation} failed: " : ''
87
90
  details = opts.reject { |key, _value| key.to_s == 'operation' }.map { |key, value| "#{key}=#{value}" }
88
91
  detail_suffix = details.empty? ? '' : " (#{details.join(' ')})"
89
- backtrace = Array(exception.backtrace).first(10).join("\n")
92
+ backtrace = Array(exception.backtrace).join("\n")
90
93
  base = "#{prefix}#{exception.class}: #{exception.message}#{detail_suffix}"
91
- return base if backtrace.empty? && level == :debug
94
+ return base if backtrace.empty? || level == :debug
92
95
  return base if backtrace.empty?
93
96
 
94
97
  "#{base}\n#{backtrace}"
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env bash
2
+ # Pre-commit hook: run RuboCop with autofix on staged Ruby files.
3
+ # Tries rubocop directly, then bundle exec. If the binary is truly
4
+ # unavailable (exit 127 / crash / Prism conflict), warns and defers
5
+ # to CI. If rubocop runs but reports offenses, fails the commit.
6
+ set -uo pipefail
7
+
8
+ run_rubocop() {
9
+ output=$("$@" -A --force-exclusion "${FILES[@]}" 2>&1)
10
+ rc=$?
11
+ if [ $rc -eq 0 ] || [ $rc -eq 1 ]; then
12
+ # rubocop ran successfully: 0 = clean, 1 = offenses found
13
+ echo "$output"
14
+ return $rc
15
+ fi
16
+ # exit > 1 means rubocop crashed / couldn't load. Preserve the output so the
17
+ # local failure is visible even when CI remains the final enforcement point.
18
+ echo "$output" >&2
19
+ return 2
20
+ }
21
+
22
+ FILES=("$@")
23
+
24
+ if run_rubocop rubocop; then
25
+ exit 0
26
+ elif [ $? -eq 1 ]; then
27
+ echo "RuboCop found offenses that could not be auto-corrected."
28
+ exit 1
29
+ fi
30
+
31
+ if run_rubocop bundle exec rubocop; then
32
+ exit 0
33
+ elif [ $? -eq 1 ]; then
34
+ echo "RuboCop found offenses that could not be auto-corrected."
35
+ exit 1
36
+ fi
37
+
38
+ echo "⚠ RuboCop not available locally (Prism conflict?) — CI will enforce."
39
+ echo " Run 'ruby -c' to at least verify syntax."
40
+ ruby -c "$@" 2>&1 || exit 1
41
+ exit 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.9
4
+ version: 1.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -93,6 +93,7 @@ files:
93
93
  - ".github/dependabot.yml"
94
94
  - ".github/workflows/ci.yml"
95
95
  - ".gitignore"
96
+ - ".pre-commit-config.yaml"
96
97
  - ".rubocop.yml"
97
98
  - AGENTS.md
98
99
  - CHANGELOG.md
@@ -134,6 +135,7 @@ files:
134
135
  - lib/legion/crypt/version.rb
135
136
  - lib/legion/logging.rb
136
137
  - lib/legion/logging/helper.rb
138
+ - scripts/pre-commit-rubocop.sh
137
139
  - sonar-project.properties
138
140
  homepage: https://github.com/LegionIO/legion-crypt
139
141
  licenses: