legion-crypt 1.5.10 → 1.5.13

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: 79ee1533023eb70b2b171f15a3961f8514e698f67681ab182a6caf1e49b1e5c5
4
- data.tar.gz: bc22e48e2161f537c1930cb20893d4e84c5a65f82f5ab7c852c0bb437c416b24
3
+ metadata.gz: 52409be9c9bdc3144775c70117e99a6d205bfe6df9485654ccaf86766d1705a8
4
+ data.tar.gz: e050109245c71fb138170689e0a7f94e0df734d260f24b484261b8c92b432527
5
5
  SHA512:
6
- metadata.gz: 1330293b0d8aef36cc9634cca92ab3019d54e1f0e9a39feb1ecff8786fe9b55b1454e929b223b23d7df71b8c0aec194a1a32a8f628e3bfca26684f3a9fc9e18a
7
- data.tar.gz: 9c8e2a8093809e44c727796dc1d1f79ba097400442b49f3bf2d216d9946e564c1c5239253599ceebd3eb99b0753fe988a97d6171729429df000ba7b4419bf16c
6
+ metadata.gz: a00f255f040dcdd2bc22fa78f73a7a0dc0609648133ce068e9a1d899d700ac3bfa932d884c8ec41392cf9e3767a76e58a13ea8c88eda0597b7cf81be6d3fcb46
7
+ data.tar.gz: 0aaa59d763953050f416aea1891c3d95f5019a42c627b47270d243c35b50414d6a93e2cba85a0ae7e5f73ee27c3f0049a4ec94463543e13e66da850c66e429c3
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
@@ -1,6 +1,26 @@
1
1
  # Legion::Crypt
2
2
 
3
- ## [Unreleased]
3
+ ## [1.5.13] - 2026-05-09
4
+
5
+ ### Removed
6
+ - Logging compat shims (`lib/legion/logging.rb` and `lib/legion/logging/helper.rb`) that redefined `Legion::Logging::Helper#log` with a `CompatLogger`, preventing TaggedLogger segment tags from rendering in log output for all modules loaded after crypt
7
+
8
+ ### Added
9
+ - `legion-json` gemspec dependency (was used but undeclared)
10
+
11
+ ## [1.5.12] - 2026-04-27
12
+
13
+ ### Fixed
14
+ - `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
15
+ - Fallback to legacy `disconnect`/`test_connection` path when `reconnect_with_fresh_creds` is not available, with explicit warning about potential stale credentials
16
+ - 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
17
+ - Lease shutdown, logging fallback, and SPIFFE socket cleanup paths now emit warnings/debug logs instead of silently swallowing unexpected failures.
18
+
19
+ ## [1.5.11] - 2026-04-27
20
+
21
+ ### Fixed
22
+ - 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.
23
+ - Crypt's logging compatibility helper now preserves full exception backtraces instead of truncating fallback log output to 10 frames.
4
24
 
5
25
  ## [1.5.10] - 2026-04-19
6
26
 
data/legion-crypt.gemspec CHANGED
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.add_dependency 'concurrent-ruby', '~> 1.3'
29
29
  spec.add_dependency 'ed25519', '~> 1.3'
30
30
  spec.add_dependency 'jwt', '>= 2.7'
31
+ spec.add_dependency 'legion-json', '>= 1.2.0'
31
32
  spec.add_dependency 'legion-logging', '>= 1.5.0'
32
33
  spec.add_dependency 'vault', '>= 0.17'
33
34
  end
@@ -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.10'
5
+ VERSION = '1.5.13'
6
6
  end
7
7
  end
@@ -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.10
4
+ version: 1.5.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
53
  version: '2.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-json
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.2.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.2.0
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: legion-logging
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -93,6 +107,7 @@ files:
93
107
  - ".github/dependabot.yml"
94
108
  - ".github/workflows/ci.yml"
95
109
  - ".gitignore"
110
+ - ".pre-commit-config.yaml"
96
111
  - ".rubocop.yml"
97
112
  - AGENTS.md
98
113
  - CHANGELOG.md
@@ -132,8 +147,7 @@ files:
132
147
  - lib/legion/crypt/vault_kerberos_auth.rb
133
148
  - lib/legion/crypt/vault_renewer.rb
134
149
  - lib/legion/crypt/version.rb
135
- - lib/legion/logging.rb
136
- - lib/legion/logging/helper.rb
150
+ - scripts/pre-commit-rubocop.sh
137
151
  - sonar-project.properties
138
152
  homepage: https://github.com/LegionIO/legion-crypt
139
153
  licenses:
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- begin
4
- helper_path = File.join(
5
- Gem::Specification.find_by_name('legion-logging').full_gem_path,
6
- 'lib/legion/logging/helper.rb'
7
- )
8
- require helper_path if File.exist?(helper_path)
9
- rescue Gem::LoadError
10
- nil
11
- end
12
-
13
- require 'legion/logging'
14
-
15
- module Legion
16
- module Logging
17
- module Helper
18
- unless const_defined?(:CompatLogger, false)
19
- CompatLogger = Class.new do
20
- %i[debug info warn error fatal unknown].each do |level|
21
- define_method(level) do |message = nil, &block|
22
- payload = block ? block.call : message
23
- return if payload.nil?
24
-
25
- if logging_supports?(level)
26
- Legion::Logging.public_send(level, payload)
27
- elsif %i[error fatal warn].include?(level)
28
- ::Kernel.warn(payload)
29
- else
30
- $stdout.puts(payload)
31
- end
32
- end
33
- end
34
-
35
- private
36
-
37
- def logging_supports?(level)
38
- return false unless Legion.const_defined?('Logging')
39
-
40
- Legion::Logging.respond_to?(level)
41
- rescue StandardError
42
- false
43
- end
44
- end
45
- end
46
-
47
- def log
48
- @log ||= CompatLogger.new
49
- end
50
-
51
- def handle_exception(exception, task_id: nil, level: :error, handled: true, **opts) # rubocop:disable Lint/UnusedMethodArgument,Style/ArgumentsForwarding
52
- message = exception_log_message(exception, level: level, **opts) # rubocop:disable Style/ArgumentsForwarding
53
-
54
- if logging_supports?(:log_exception)
55
- Legion::Logging.log_exception(exception, level: level, lex: 'crypt', component_type: :helper)
56
- return
57
- end
58
- if logging_supports?(level)
59
- Legion::Logging.public_send(level, message)
60
- return
61
- end
62
- if logging_supports?(:error)
63
- Legion::Logging.error(message)
64
- return
65
- end
66
- if logging_supports?(:warn)
67
- Legion::Logging.warn(message)
68
- return
69
- end
70
-
71
- ::Kernel.warn(message)
72
- end
73
-
74
- private
75
-
76
- def logging_supports?(level)
77
- return false unless Legion.const_defined?('Logging')
78
-
79
- Legion::Logging.respond_to?(level)
80
- rescue StandardError
81
- false
82
- end
83
-
84
- def exception_log_message(exception, level:, **opts)
85
- operation = opts[:operation] || opts['operation']
86
- prefix = operation ? "#{operation} failed: " : ''
87
- details = opts.reject { |key, _value| key.to_s == 'operation' }.map { |key, value| "#{key}=#{value}" }
88
- detail_suffix = details.empty? ? '' : " (#{details.join(' ')})"
89
- backtrace = Array(exception.backtrace).first(10).join("\n")
90
- base = "#{prefix}#{exception.class}: #{exception.message}#{detail_suffix}"
91
- return base if backtrace.empty? || level == :debug
92
- return base if backtrace.empty?
93
-
94
- "#{base}\n#{backtrace}"
95
- end
96
- end
97
- end
98
- end
@@ -1,58 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'logger'
4
-
5
- begin
6
- gem_root = Gem::Specification.find_by_name('legion-logging').full_gem_path
7
- upstream_logging = File.join(gem_root, 'lib/legion/logging.rb')
8
- require upstream_logging if File.exist?(upstream_logging)
9
- rescue Gem::LoadError
10
- nil
11
- end
12
-
13
- module Legion
14
- module Logging
15
- class << self
16
- unless method_defined?(:setup)
17
- def setup(level: 'info', **_opts)
18
- logger.level = normalize_level(level)
19
- self
20
- end
21
-
22
- def logger
23
- @logger ||= Logger.new($stdout).tap do |instance|
24
- instance.progname = 'legion-crypt'
25
- end
26
- end
27
-
28
- def log_exception(exception, lex: nil, component_type: nil, **_opts)
29
- prefix = [lex, component_type].compact.join('.')
30
- payload = prefix.empty? ? exception.message : "#{prefix}: #{exception.message}"
31
- error(payload)
32
- end
33
-
34
- %i[debug info warn error fatal unknown].each do |level_name|
35
- define_method(level_name) do |message = nil, &block|
36
- payload = block ? block.call : message
37
- return if payload.nil?
38
-
39
- logger.public_send(level_name, payload)
40
- end
41
- end
42
-
43
- private
44
-
45
- def normalize_level(level)
46
- case level.to_s.downcase
47
- when 'debug' then Logger::DEBUG
48
- when 'info' then Logger::INFO
49
- when 'warn' then Logger::WARN
50
- when 'error' then Logger::ERROR
51
- when 'fatal' then Logger::FATAL
52
- else Logger::UNKNOWN
53
- end
54
- end
55
- end
56
- end
57
- end
58
- end