legion-crypt 0.3.0 → 1.3.0
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/.github/workflows/ci.yml +16 -0
- data/.gitignore +5 -1
- data/.rubocop.yml +41 -12
- data/CHANGELOG.md +31 -0
- data/CLAUDE.md +149 -0
- data/Gemfile +9 -0
- data/LICENSE +191 -0
- data/README.md +96 -2
- data/legion-crypt.gemspec +19 -28
- data/lib/legion/crypt/cipher.rb +7 -54
- data/lib/legion/crypt/cluster_secret.rb +127 -0
- data/lib/legion/crypt/jwt.rb +75 -0
- data/lib/legion/crypt/lease_manager.rb +199 -0
- data/lib/legion/crypt/settings.rb +27 -12
- data/lib/legion/crypt/vault.rb +3 -1
- data/lib/legion/crypt/vault_jwt_auth.rb +92 -0
- data/lib/legion/crypt/vault_renewer.rb +2 -0
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +47 -1
- metadata +35 -143
- data/.circleci/config.yml +0 -105
- data/.rspec +0 -3
- data/Gemfile.lock +0 -123
- data/LICENSE.txt +0 -21
- data/Rakefile +0 -8
- data/bitbucket-pipelines.yml +0 -17
- data/settings/transport.json +0 -5
data/lib/legion/crypt/cipher.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'securerandom'
|
|
4
|
+
require 'legion/crypt/cluster_secret'
|
|
2
5
|
|
|
3
6
|
module Legion
|
|
4
7
|
module Crypt
|
|
5
8
|
module Cipher
|
|
9
|
+
include Legion::Crypt::ClusterSecret
|
|
10
|
+
|
|
6
11
|
def encrypt(message)
|
|
7
12
|
cipher = OpenSSL::Cipher.new('aes-256-cbc')
|
|
8
13
|
cipher.encrypt
|
|
@@ -11,7 +16,7 @@ module Legion
|
|
|
11
16
|
{ enciphered_message: Base64.encode64(cipher.update(message) + cipher.final), iv: Base64.encode64(iv) }
|
|
12
17
|
end
|
|
13
18
|
|
|
14
|
-
def decrypt(message,
|
|
19
|
+
def decrypt(message, init_vector)
|
|
15
20
|
until cs.is_a?(String) || Legion::Settings[:client][:shutting_down]
|
|
16
21
|
Legion::Logging.debug('sleeping Legion::Crypt.decrypt due to CS not being set')
|
|
17
22
|
sleep(0.5)
|
|
@@ -20,7 +25,7 @@ module Legion
|
|
|
20
25
|
decipher = OpenSSL::Cipher.new('aes-256-cbc')
|
|
21
26
|
decipher.decrypt
|
|
22
27
|
decipher.key = cs
|
|
23
|
-
decipher.iv = Base64.decode64(
|
|
28
|
+
decipher.iv = Base64.decode64(init_vector)
|
|
24
29
|
message = Base64.decode64(message)
|
|
25
30
|
decipher.update(message) + decipher.final
|
|
26
31
|
end
|
|
@@ -46,58 +51,6 @@ module Legion
|
|
|
46
51
|
OpenSSL::PKey::RSA.new 2048
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
|
-
|
|
50
|
-
def cs
|
|
51
|
-
@cs ||= Digest::SHA256.digest(fetch_cs)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def fetch_cs # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity,Metrics/CyclomaticComplexity
|
|
55
|
-
if Legion::Settings[:crypt][:vault][:read_cluster_secret] && Legion::Settings[:crypt][:vault][:connected] && Legion::Crypt.exist?('crypt') # rubocop:disable Layout/LineLength
|
|
56
|
-
Legion::Crypt.get('crypt')[:cluster_secret]
|
|
57
|
-
elsif Legion::Settings[:crypt][:cluster_secret].is_a? String
|
|
58
|
-
Legion::Settings[:crypt][:cluster_secret]
|
|
59
|
-
elsif Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero?
|
|
60
|
-
Legion::Settings[:crypt][:cluster_secret] = generate_secure_random
|
|
61
|
-
elsif Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.positive?
|
|
62
|
-
require 'legion/transport/messages/request_cluster_secret'
|
|
63
|
-
Legion::Logging.info 'Requesting cluster secret via public key'
|
|
64
|
-
start = Time.now
|
|
65
|
-
Legion::Transport::Messages::RequestClusterSecret.new.publish
|
|
66
|
-
sleep_time = 0.001
|
|
67
|
-
until !Legion::Settings[:crypt][:cluster_secret].nil? || (Time.now - start) > Legion::Settings[:crypt][:cluster_secret_timeout]
|
|
68
|
-
sleep(sleep_time)
|
|
69
|
-
sleep_time *= 2 unless sleep_time > 0.5
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
if Legion::Settings[:crypt][:cluster_secret].nil?
|
|
73
|
-
Legion::Logging.warn 'Cluster secret is still nil'
|
|
74
|
-
else
|
|
75
|
-
Legion::Logging.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms"
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
rescue StandardError => e
|
|
79
|
-
Legion::Logging.error(e.message)
|
|
80
|
-
Legion::Logging.error(e.backtrace)
|
|
81
|
-
ensure
|
|
82
|
-
Legion::Settings[:crypt][:cluster_secret] = generate_secure_random unless Legion::Settings[:crypt].key? :cluster_secret
|
|
83
|
-
nil if Legion::Settings[:crypt][:cluster_secret].nil?
|
|
84
|
-
|
|
85
|
-
Legion::Settings[:crypt][:cs_encrypt_ready] = true
|
|
86
|
-
push_cs_to_vault if Legion::Settings[:crypt][:vault][:push_cs_to_vault]
|
|
87
|
-
|
|
88
|
-
return Legion::Settings[:crypt][:cluster_secret] # rubocop:disable Lint/EnsureReturn
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def push_cs_to_vault
|
|
92
|
-
return false unless Legion::Settings[:crypt][:vault][:connected] && Legion::Settings[:crypt][:cluster_secret]
|
|
93
|
-
|
|
94
|
-
Legion::Logging.info 'Pushing Cluster Secret to Vault'
|
|
95
|
-
Legion::Crypt.write('cluster', secret: Legion::Settings[:crypt][:cluster_secret])
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def generate_secure_random
|
|
99
|
-
SecureRandom.uuid
|
|
100
|
-
end
|
|
101
54
|
end
|
|
102
55
|
end
|
|
103
56
|
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Crypt
|
|
7
|
+
module ClusterSecret
|
|
8
|
+
def find_cluster_secret
|
|
9
|
+
%i[from_settings from_vault from_transport generate_secure_random].each do |method|
|
|
10
|
+
result = send(method)
|
|
11
|
+
next if result.nil?
|
|
12
|
+
|
|
13
|
+
unless validate_hex(result)
|
|
14
|
+
Legion::Logging.warn("Legion::Crypt.#{method} gave a value but it isn't a valid hex")
|
|
15
|
+
next
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
set_cluster_secret(result, method != :from_vault)
|
|
19
|
+
return result
|
|
20
|
+
end
|
|
21
|
+
return unless only_member?
|
|
22
|
+
|
|
23
|
+
key = generate_secure_random
|
|
24
|
+
set_cluster_secret(key)
|
|
25
|
+
key
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def from_vault
|
|
29
|
+
return nil unless method_defined? :get
|
|
30
|
+
return nil unless Legion::Settings[:crypt][:vault][:read_cluster_secret]
|
|
31
|
+
return nil unless Legion::Settings[:crypt][:vault][:connected]
|
|
32
|
+
return nil unless Legion::Crypt.exist?('crypt')
|
|
33
|
+
|
|
34
|
+
get('crypt')[:cluster_secret]
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def from_settings
|
|
40
|
+
Legion::Settings[:crypt][:cluster_secret]
|
|
41
|
+
end
|
|
42
|
+
alias cluster_secret from_settings
|
|
43
|
+
|
|
44
|
+
def from_transport
|
|
45
|
+
return nil unless Legion::Settings[:transport][:connected]
|
|
46
|
+
|
|
47
|
+
require 'legion/transport/messages/request_cluster_secret'
|
|
48
|
+
Legion::Logging.info 'Requesting cluster secret via public key'
|
|
49
|
+
Legion::Logging.warn 'cluster_secret already set but we are requesting a new value' unless from_settings.nil?
|
|
50
|
+
start = Time.now
|
|
51
|
+
Legion::Transport::Messages::RequestClusterSecret.new.publish
|
|
52
|
+
sleep_time = 0.001
|
|
53
|
+
until !Legion::Settings[:crypt][:cluster_secret].nil? || (Time.now - start) > cluster_secret_timeout
|
|
54
|
+
sleep(sleep_time)
|
|
55
|
+
sleep_time *= 2 unless sleep_time > 0.5
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
unless from_settings.nil?
|
|
59
|
+
Legion::Logging.info "Received cluster secret in #{((Time.new - start) * 1000.0).round}ms"
|
|
60
|
+
return from_settings
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Legion::Logging.error 'Cluster secret is still unknown!'
|
|
64
|
+
nil
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
Legion::Logging.error e.message
|
|
67
|
+
Legion::Logging.error e.backtrace[0..10]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def force_cluster_secret
|
|
71
|
+
Legion::Settings[:crypt][:force_cluster_secret] || true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def settings_push_vault
|
|
75
|
+
Legion::Settings[:crypt][:vault][:push_cs_to_vault] || true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def only_member?
|
|
79
|
+
Legion::Transport::Queue.new('node.crypt', passive: true).consumer_count.zero?
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def set_cluster_secret(value, push_to_vault = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
85
|
+
raise TypeError unless value.to_i(32).to_s(32).rjust(value.length, '0') == value.downcase
|
|
86
|
+
|
|
87
|
+
Legion::Settings[:crypt][:cs_encrypt_ready] = true
|
|
88
|
+
push_cs_to_vault if push_to_vault && settings_push_vault
|
|
89
|
+
|
|
90
|
+
Legion::Settings[:crypt][:cluster_secret] = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def push_cs_to_vault
|
|
94
|
+
return false unless Legion::Settings[:crypt][:vault][:connected] && Legion::Settings[:crypt][:cluster_secret]
|
|
95
|
+
|
|
96
|
+
Legion::Logging.info 'Pushing Cluster Secret to Vault'
|
|
97
|
+
Legion::Crypt.write('cluster', secret: Legion::Settings[:crypt][:cluster_secret])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def cluster_secret_timeout
|
|
101
|
+
Legion::Settings[:crypt][:cluster_secret_timeout] || 5
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def secret_length
|
|
105
|
+
Legion::Settings[:crypt][:cluster_lenth] || 32
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def generate_secure_random(length = secret_length)
|
|
109
|
+
SecureRandom.hex(length)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def cs
|
|
113
|
+
@cs ||= Digest::SHA256.digest(find_cluster_secret)
|
|
114
|
+
rescue StandardError => e
|
|
115
|
+
Legion::Logging.error e.message
|
|
116
|
+
Legion::Logging.error e.backtrace[0..10]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def validate_hex(value, length = secret_length)
|
|
120
|
+
return false unless value.is_a?(String)
|
|
121
|
+
return false if value.empty?
|
|
122
|
+
|
|
123
|
+
value.to_i(length).to_s(length).rjust(value.length, '0') == value.downcase
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Crypt
|
|
8
|
+
module JWT
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
class ExpiredTokenError < Error; end
|
|
11
|
+
class InvalidTokenError < Error; end
|
|
12
|
+
class DecodeError < Error; end
|
|
13
|
+
|
|
14
|
+
SUPPORTED_ALGORITHMS = %w[HS256 RS256].freeze
|
|
15
|
+
|
|
16
|
+
def self.issue(payload, signing_key:, algorithm: 'HS256', ttl: 3600, issuer: 'legion')
|
|
17
|
+
validate_algorithm!(algorithm)
|
|
18
|
+
|
|
19
|
+
now = Time.now.to_i
|
|
20
|
+
claims = {
|
|
21
|
+
iss: issuer,
|
|
22
|
+
iat: now,
|
|
23
|
+
exp: now + ttl,
|
|
24
|
+
jti: SecureRandom.uuid
|
|
25
|
+
}.merge(payload)
|
|
26
|
+
|
|
27
|
+
::JWT.encode(claims, signing_key, algorithm)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.verify(token, verification_key:, **opts)
|
|
31
|
+
algorithm = opts.fetch(:algorithm, 'HS256')
|
|
32
|
+
verify_expiration = opts.fetch(:verify_expiration, true)
|
|
33
|
+
verify_issuer = opts.fetch(:verify_issuer, true)
|
|
34
|
+
issuer = opts.fetch(:issuer, 'legion')
|
|
35
|
+
|
|
36
|
+
validate_algorithm!(algorithm)
|
|
37
|
+
|
|
38
|
+
decode_opts = {
|
|
39
|
+
algorithm: algorithm,
|
|
40
|
+
verify_expiration: verify_expiration,
|
|
41
|
+
verify_iss: verify_issuer
|
|
42
|
+
}
|
|
43
|
+
decode_opts[:iss] = issuer if verify_issuer
|
|
44
|
+
|
|
45
|
+
payload, _header = ::JWT.decode(token, verification_key, true, decode_opts)
|
|
46
|
+
symbolize_keys(payload)
|
|
47
|
+
rescue ::JWT::ExpiredSignature
|
|
48
|
+
raise ExpiredTokenError, 'token has expired'
|
|
49
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
50
|
+
raise InvalidTokenError, 'token signature verification failed'
|
|
51
|
+
rescue ::JWT::DecodeError => e
|
|
52
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.decode(token)
|
|
56
|
+
payload, _header = ::JWT.decode(token, nil, false)
|
|
57
|
+
symbolize_keys(payload)
|
|
58
|
+
rescue ::JWT::DecodeError => e
|
|
59
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.validate_algorithm!(algorithm)
|
|
63
|
+
return if SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(', ')}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.symbolize_keys(hash)
|
|
69
|
+
hash.transform_keys(&:to_sym)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private_class_method :validate_algorithm!, :symbolize_keys
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Crypt
|
|
7
|
+
class LeaseManager
|
|
8
|
+
include Singleton
|
|
9
|
+
|
|
10
|
+
RENEWAL_CHECK_INTERVAL = 5
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@lease_cache = {}
|
|
14
|
+
@active_leases = {}
|
|
15
|
+
@refs = {}
|
|
16
|
+
@running = false
|
|
17
|
+
@renewal_thread = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start(definitions)
|
|
21
|
+
return if definitions.nil? || definitions.empty?
|
|
22
|
+
|
|
23
|
+
definitions.each do |name, opts|
|
|
24
|
+
path = opts['path'] || opts[:path]
|
|
25
|
+
next unless path
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
response = ::Vault.logical.read(path)
|
|
29
|
+
next unless response
|
|
30
|
+
|
|
31
|
+
@lease_cache[name] = response.data || {}
|
|
32
|
+
@active_leases[name] = {
|
|
33
|
+
lease_id: response.lease_id,
|
|
34
|
+
lease_duration: response.lease_duration,
|
|
35
|
+
renewable: response.renewable,
|
|
36
|
+
expires_at: Time.now + (response.lease_duration || 0),
|
|
37
|
+
fetched_at: Time.now
|
|
38
|
+
}
|
|
39
|
+
log_debug("LeaseManager: fetched lease for '#{name}' from #{path}")
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
log_warn("LeaseManager: failed to fetch lease '#{name}' from #{path}: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch(name, key)
|
|
47
|
+
data = @lease_cache[name]
|
|
48
|
+
return nil unless data
|
|
49
|
+
|
|
50
|
+
data[key.to_sym] || data[key.to_s]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def lease_data(name)
|
|
54
|
+
@lease_cache[name]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :active_leases
|
|
58
|
+
|
|
59
|
+
def register_ref(name, key, path)
|
|
60
|
+
@refs[name] ||= {}
|
|
61
|
+
@refs[name][key] = path
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def push_to_settings(name)
|
|
65
|
+
refs = @refs[name]
|
|
66
|
+
return if refs.nil? || refs.empty?
|
|
67
|
+
|
|
68
|
+
data = @lease_cache[name]
|
|
69
|
+
return unless data
|
|
70
|
+
|
|
71
|
+
refs.each do |key, path|
|
|
72
|
+
value = data[key.to_sym] || data[key.to_s]
|
|
73
|
+
write_setting(path, value)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
log_debug("Lease '#{name}' rotated — updated #{refs.size} settings reference(s)")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def start_renewal_thread
|
|
80
|
+
return if renewal_thread_alive?
|
|
81
|
+
|
|
82
|
+
@running = true
|
|
83
|
+
@renewal_thread = Thread.new { renewal_loop }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def renewal_thread_alive?
|
|
87
|
+
@renewal_thread&.alive? || false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def shutdown
|
|
91
|
+
stop_renewal_thread
|
|
92
|
+
|
|
93
|
+
@active_leases.each do |name, meta|
|
|
94
|
+
lease_id = meta[:lease_id]
|
|
95
|
+
next if lease_id.nil? || lease_id.empty?
|
|
96
|
+
|
|
97
|
+
begin
|
|
98
|
+
::Vault.sys.revoke(lease_id)
|
|
99
|
+
log_debug("LeaseManager: revoked lease '#{name}' (#{lease_id})")
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
log_warn("LeaseManager: failed to revoke lease '#{name}' (#{lease_id}): #{e.message}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
@lease_cache.clear
|
|
106
|
+
@active_leases.clear
|
|
107
|
+
@refs.clear
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reset!
|
|
111
|
+
@running = false
|
|
112
|
+
@lease_cache.clear
|
|
113
|
+
@active_leases.clear
|
|
114
|
+
@refs.clear
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def stop_renewal_thread
|
|
120
|
+
@running = false
|
|
121
|
+
if @renewal_thread&.alive?
|
|
122
|
+
@renewal_thread.kill
|
|
123
|
+
@renewal_thread.join(2)
|
|
124
|
+
end
|
|
125
|
+
@renewal_thread = nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def renewal_loop
|
|
129
|
+
while @running
|
|
130
|
+
sleep(RENEWAL_CHECK_INTERVAL)
|
|
131
|
+
renew_approaching_leases if @running
|
|
132
|
+
end
|
|
133
|
+
rescue StandardError => e
|
|
134
|
+
log_warn("LeaseManager: renewal loop error: #{e.message}")
|
|
135
|
+
retry if @running
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def renew_approaching_leases
|
|
139
|
+
@active_leases.each do |name, lease|
|
|
140
|
+
next unless lease[:renewable]
|
|
141
|
+
next unless approaching_expiry?(lease)
|
|
142
|
+
|
|
143
|
+
renew_lease(name, lease)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def renew_lease(name, lease)
|
|
148
|
+
response = ::Vault.sys.renew(lease[:lease_id])
|
|
149
|
+
lease[:expires_at] = Time.now + (response.lease_duration || 0)
|
|
150
|
+
|
|
151
|
+
if response.data && response.data != @lease_cache[name]
|
|
152
|
+
@lease_cache[name] = response.data
|
|
153
|
+
push_to_settings(name)
|
|
154
|
+
end
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
log_warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def approaching_expiry?(lease)
|
|
160
|
+
expires_at = lease[:expires_at]
|
|
161
|
+
lease_duration = lease[:lease_duration]
|
|
162
|
+
|
|
163
|
+
return true if expires_at.nil? || lease_duration.nil?
|
|
164
|
+
|
|
165
|
+
remaining = expires_at - Time.now
|
|
166
|
+
remaining < (lease_duration * 0.5)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def write_setting(path, value)
|
|
170
|
+
return if path.nil? || path.empty?
|
|
171
|
+
|
|
172
|
+
target = path[1..-2].reduce(Legion::Settings[path[0]]) do |node, segment|
|
|
173
|
+
break nil unless node.is_a?(Hash)
|
|
174
|
+
|
|
175
|
+
node[segment]
|
|
176
|
+
end
|
|
177
|
+
target[path.last] = value if target.is_a?(Hash)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
log_warn("LeaseManager: failed to write setting at #{path.join('.')}: #{e.message}")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def log_debug(message)
|
|
183
|
+
if defined?(Legion::Logging)
|
|
184
|
+
Legion::Logging.debug(message)
|
|
185
|
+
else
|
|
186
|
+
$stdout.puts("[DEBUG] #{message}")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def log_warn(message)
|
|
191
|
+
if defined?(Legion::Logging)
|
|
192
|
+
Legion::Logging.warn(message)
|
|
193
|
+
else
|
|
194
|
+
warn("[WARN] #{message}")
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -1,30 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Legion
|
|
2
4
|
module Crypt
|
|
3
5
|
module Settings
|
|
4
6
|
def self.default
|
|
5
7
|
{
|
|
6
|
-
vault:
|
|
8
|
+
vault: vault,
|
|
9
|
+
jwt: jwt,
|
|
7
10
|
cs_encrypt_ready: false,
|
|
8
|
-
dynamic_keys:
|
|
9
|
-
cluster_secret:
|
|
11
|
+
dynamic_keys: true,
|
|
12
|
+
cluster_secret: nil,
|
|
10
13
|
save_private_key: true,
|
|
11
14
|
read_private_key: true
|
|
12
15
|
}
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
def self.jwt
|
|
19
|
+
{
|
|
20
|
+
enabled: true,
|
|
21
|
+
default_algorithm: 'HS256',
|
|
22
|
+
default_ttl: 3600,
|
|
23
|
+
issuer: 'legion',
|
|
24
|
+
verify_expiration: true,
|
|
25
|
+
verify_issuer: true
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
15
29
|
def self.vault
|
|
16
30
|
{
|
|
17
|
-
enabled:
|
|
18
|
-
protocol:
|
|
19
|
-
address:
|
|
20
|
-
port:
|
|
21
|
-
token:
|
|
22
|
-
connected:
|
|
23
|
-
renewer_time:
|
|
24
|
-
renewer:
|
|
31
|
+
enabled: !Gem::Specification.find_by_name('vault').nil?,
|
|
32
|
+
protocol: 'http',
|
|
33
|
+
address: 'localhost',
|
|
34
|
+
port: 8200,
|
|
35
|
+
token: ENV['VAULT_DEV_ROOT_TOKEN_ID'] || ENV['VAULT_TOKEN_ID'] || nil,
|
|
36
|
+
connected: false,
|
|
37
|
+
renewer_time: 5,
|
|
38
|
+
renewer: true,
|
|
25
39
|
push_cluster_secret: true,
|
|
26
40
|
read_cluster_secret: true,
|
|
27
|
-
kv_path:
|
|
41
|
+
kv_path: ENV['LEGION_VAULT_KV_PATH'] || 'legion',
|
|
42
|
+
leases: {}
|
|
28
43
|
}
|
|
29
44
|
end
|
|
30
45
|
end
|
data/lib/legion/crypt/vault.rb
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'vault'
|
|
2
4
|
|
|
3
5
|
module Legion
|
|
@@ -9,7 +11,7 @@ module Legion
|
|
|
9
11
|
Legion::Settings[:crypt][:vault]
|
|
10
12
|
end
|
|
11
13
|
|
|
12
|
-
def connect_vault
|
|
14
|
+
def connect_vault
|
|
13
15
|
@sessions = []
|
|
14
16
|
::Vault.address = "#{Legion::Settings[:crypt][:vault][:protocol]}://#{Legion::Settings[:crypt][:vault][:address]}:#{Legion::Settings[:crypt][:vault][:port]}" # rubocop:disable Layout/LineLength
|
|
15
17
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Crypt
|
|
5
|
+
# Vault JWT auth backend integration.
|
|
6
|
+
#
|
|
7
|
+
# Allows Legion workers to authenticate to Vault using JWT tokens
|
|
8
|
+
# via Vault's JWT/OIDC auth method. The worker presents a signed JWT
|
|
9
|
+
# and receives a Vault token with policies scoped to the worker's role.
|
|
10
|
+
#
|
|
11
|
+
# Vault config prerequisites:
|
|
12
|
+
# vault auth enable jwt
|
|
13
|
+
# vault write auth/jwt/config jwks_url="..." (or bound_issuer + jwt_validation_pubkeys)
|
|
14
|
+
# vault write auth/jwt/role/legion-worker bound_audiences="legion" ...
|
|
15
|
+
module VaultJwtAuth
|
|
16
|
+
DEFAULT_AUTH_PATH = 'auth/jwt/login'
|
|
17
|
+
DEFAULT_ROLE = 'legion-worker'
|
|
18
|
+
|
|
19
|
+
class AuthError < StandardError; end
|
|
20
|
+
|
|
21
|
+
# Authenticate to Vault using a JWT token.
|
|
22
|
+
# Returns a Vault token string on success.
|
|
23
|
+
#
|
|
24
|
+
# @param jwt [String] Signed JWT token (issued by Legion or Entra ID)
|
|
25
|
+
# @param role [String] Vault JWT auth role name (default: 'legion-worker')
|
|
26
|
+
# @param auth_path [String] Vault auth mount path (default: 'auth/jwt/login')
|
|
27
|
+
# @return [Hash] { token:, lease_duration:, policies:, metadata: }
|
|
28
|
+
def self.login(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
|
|
29
|
+
raise AuthError, 'Vault is not connected' unless vault_connected?
|
|
30
|
+
|
|
31
|
+
response = ::Vault.logical.write(
|
|
32
|
+
auth_path,
|
|
33
|
+
role: role,
|
|
34
|
+
jwt: jwt
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
raise AuthError, 'Vault JWT auth returned no auth data' unless response&.auth
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
token: response.auth.client_token,
|
|
41
|
+
lease_duration: response.auth.lease_duration,
|
|
42
|
+
renewable: response.auth.renewable,
|
|
43
|
+
policies: response.auth.policies,
|
|
44
|
+
metadata: response.auth.metadata
|
|
45
|
+
}
|
|
46
|
+
rescue ::Vault::HTTPClientError => e
|
|
47
|
+
raise AuthError, "Vault JWT auth failed: #{e.message}"
|
|
48
|
+
rescue ::Vault::HTTPServerError => e
|
|
49
|
+
raise AuthError, "Vault server error during JWT auth: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Authenticate and set the Vault client token for subsequent operations.
|
|
53
|
+
# This replaces the current Vault token with the JWT-authenticated one.
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash] Same as login
|
|
56
|
+
def self.login!(jwt:, role: DEFAULT_ROLE, auth_path: DEFAULT_AUTH_PATH)
|
|
57
|
+
result = login(jwt: jwt, role: role, auth_path: auth_path)
|
|
58
|
+
::Vault.token = result[:token]
|
|
59
|
+
Legion::Logging.info "[crypt:vault_jwt] authenticated via JWT auth, policies=#{result[:policies].join(',')}"
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Issue a Legion JWT and use it to authenticate to Vault in one step.
|
|
64
|
+
# Convenience method for workers that need Vault access.
|
|
65
|
+
#
|
|
66
|
+
# @param worker_id [String] Digital worker ID
|
|
67
|
+
# @param owner_msid [String] Worker's owner MSID
|
|
68
|
+
# @param role [String] Vault JWT auth role name
|
|
69
|
+
# @return [Hash] Same as login
|
|
70
|
+
def self.worker_login(worker_id:, owner_msid:, role: DEFAULT_ROLE)
|
|
71
|
+
jwt = Legion::Crypt::JWT.issue(
|
|
72
|
+
{ worker_id: worker_id, sub: owner_msid, scope: 'vault', aud: 'legion' },
|
|
73
|
+
signing_key: Legion::Crypt.cluster_secret,
|
|
74
|
+
ttl: 300,
|
|
75
|
+
issuer: 'legion'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
login(jwt: jwt, role: role)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.vault_connected?
|
|
82
|
+
defined?(::Vault) &&
|
|
83
|
+
defined?(Legion::Settings) &&
|
|
84
|
+
Legion::Settings[:crypt][:vault][:connected] == true
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_class_method :vault_connected?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|