legion-crypt 1.5.10 → 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 +4 -4
- data/.gitignore +1 -0
- data/.pre-commit-config.yaml +29 -0
- data/CHANGELOG.md +14 -0
- data/lib/legion/crypt/cipher.rb +67 -0
- data/lib/legion/crypt/lease_manager.rb +33 -11
- data/lib/legion/crypt/spiffe/workload_api_client.rb +9 -1
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/logging/helper.rb +8 -5
- data/scripts/pre-commit-rubocop.sh +41 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a4af81549d8e946acfc92ec4036496fce4af3d1be6fc10cba534298434f0c94a
|
|
4
|
+
data.tar.gz: 7e70028fa24e9760ff791f49f88817b37c9ee009b21fdc7dae9382a49306e8ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 96269db0a9859015418df236468d0eff5b08490e1eb7bc55c7507199dda579924273979405d9e724b3d1237b7855ecc7b949fabdbc15dc378994ad19930112c9
|
|
7
|
+
data.tar.gz: b112f3eb47b538549e842eaa8a766e15c1934650f010082ec892a4b05fa995746dcff25ffeed90a2e625e09ee4d38d21a9d399d9787c6fd6dd1598f6895fc28f
|
data/.gitignore
CHANGED
|
@@ -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,20 @@
|
|
|
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
|
+
|
|
5
19
|
## [1.5.10] - 2026-04-19
|
|
6
20
|
|
|
7
21
|
### Fixed
|
data/lib/legion/crypt/cipher.rb
CHANGED
|
@@ -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
|
|
253
|
-
rescue StandardError # best effort on crash
|
|
254
|
-
|
|
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
|
-
|
|
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: :
|
|
486
|
-
log.
|
|
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
|
|
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
|
|
data/lib/legion/crypt/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -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,7 +89,7 @@ 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).
|
|
92
|
+
backtrace = Array(exception.backtrace).join("\n")
|
|
90
93
|
base = "#{prefix}#{exception.class}: #{exception.message}#{detail_suffix}"
|
|
91
94
|
return base if backtrace.empty? || level == :debug
|
|
92
95
|
return base if backtrace.empty?
|
|
@@ -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.
|
|
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:
|