legion-crypt 1.2.0 → 1.4.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/.rubocop.yml +41 -14
- data/CHANGELOG.md +37 -1
- data/CLAUDE.md +149 -0
- data/Gemfile +4 -0
- data/LICENSE +5 -15
- data/README.md +107 -24
- data/legion-crypt.gemspec +13 -15
- data/lib/legion/crypt/cipher.rb +4 -2
- data/lib/legion/crypt/cluster_secret.rb +10 -4
- data/lib/legion/crypt/jwks_client.rb +102 -0
- data/lib/legion/crypt/jwt.rb +130 -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 +53 -0
- metadata +27 -47
- data/.github/workflows/rubocop-analysis.yml +0 -41
- data/.github/workflows/sourcehawk-scan.yml +0 -20
- data/CODE_OF_CONDUCT.md +0 -75
- data/CONTRIBUTING.md +0 -55
- data/INDIVIDUAL_CONTRIBUTOR_LICENSE.md +0 -30
- data/NOTICE.txt +0 -9
- data/SECURITY.md +0 -9
- data/attribution.txt +0 -1
- data/sourcehawk.yml +0 -4
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
require 'jwt'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Crypt
|
|
11
|
+
module JwksClient
|
|
12
|
+
CACHE_TTL = 3600
|
|
13
|
+
|
|
14
|
+
@cache = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def fetch_keys(jwks_url)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
response = http_get(jwks_url)
|
|
21
|
+
jwks_data = parse_response(response)
|
|
22
|
+
keys = parse_jwks(jwks_data)
|
|
23
|
+
|
|
24
|
+
@cache[jwks_url] = { keys: keys, fetched_at: Time.now }
|
|
25
|
+
keys
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_key(jwks_url, kid)
|
|
30
|
+
cached = @mutex.synchronize { @cache[jwks_url] }
|
|
31
|
+
|
|
32
|
+
if cached && !expired?(cached[:fetched_at])
|
|
33
|
+
key = cached[:keys][kid]
|
|
34
|
+
return key if key
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Re-fetch once on cache miss or expiry
|
|
38
|
+
keys = fetch_keys(jwks_url)
|
|
39
|
+
key = keys[kid]
|
|
40
|
+
return key if key
|
|
41
|
+
|
|
42
|
+
raise Legion::Crypt::JWT::InvalidTokenError, "signing key not found: #{kid}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def clear_cache
|
|
46
|
+
@mutex.synchronize { @cache = {} }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def expired?(fetched_at)
|
|
52
|
+
Time.now - fetched_at > CACHE_TTL
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def http_get(url)
|
|
56
|
+
uri = URI.parse(url)
|
|
57
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
58
|
+
http.use_ssl = uri.scheme == 'https'
|
|
59
|
+
http.open_timeout = 10
|
|
60
|
+
http.read_timeout = 10
|
|
61
|
+
|
|
62
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
63
|
+
response = http.request(request)
|
|
64
|
+
|
|
65
|
+
raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
66
|
+
|
|
67
|
+
response.body
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
raise Legion::Crypt::JWT::Error, "failed to fetch JWKS: #{e.message}" unless e.is_a?(Legion::Crypt::JWT::Error)
|
|
70
|
+
|
|
71
|
+
raise
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def parse_response(body)
|
|
75
|
+
parsed = ::JSON.parse(body)
|
|
76
|
+
raise Legion::Crypt::JWT::Error, 'invalid JWKS response: missing keys' unless parsed.is_a?(Hash) && parsed['keys'].is_a?(Array)
|
|
77
|
+
|
|
78
|
+
parsed
|
|
79
|
+
rescue ::JSON::ParserError => e
|
|
80
|
+
raise Legion::Crypt::JWT::Error, "invalid JWKS response: #{e.message}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def parse_jwks(jwks_data)
|
|
84
|
+
keys = {}
|
|
85
|
+
|
|
86
|
+
jwks_data['keys'].each do |jwk_hash|
|
|
87
|
+
kid = jwk_hash['kid']
|
|
88
|
+
next unless kid
|
|
89
|
+
|
|
90
|
+
jwk = ::JWT::JWK.new(jwk_hash)
|
|
91
|
+
keys[kid] = jwk.public_key
|
|
92
|
+
rescue StandardError
|
|
93
|
+
# Skip malformed keys, continue with valid ones
|
|
94
|
+
next
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
keys
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jwt'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'legion/crypt/jwks_client'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Crypt
|
|
9
|
+
module JWT
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
class ExpiredTokenError < Error; end
|
|
12
|
+
class InvalidTokenError < Error; end
|
|
13
|
+
class DecodeError < Error; end
|
|
14
|
+
|
|
15
|
+
SUPPORTED_ALGORITHMS = %w[HS256 RS256].freeze
|
|
16
|
+
|
|
17
|
+
def self.issue(payload, signing_key:, algorithm: 'HS256', ttl: 3600, issuer: 'legion')
|
|
18
|
+
validate_algorithm!(algorithm)
|
|
19
|
+
|
|
20
|
+
now = Time.now.to_i
|
|
21
|
+
claims = {
|
|
22
|
+
iss: issuer,
|
|
23
|
+
iat: now,
|
|
24
|
+
exp: now + ttl,
|
|
25
|
+
jti: SecureRandom.uuid
|
|
26
|
+
}.merge(payload)
|
|
27
|
+
|
|
28
|
+
::JWT.encode(claims, signing_key, algorithm)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.verify(token, verification_key:, **opts)
|
|
32
|
+
algorithm = opts.fetch(:algorithm, 'HS256')
|
|
33
|
+
verify_expiration = opts.fetch(:verify_expiration, true)
|
|
34
|
+
verify_issuer = opts.fetch(:verify_issuer, true)
|
|
35
|
+
issuer = opts.fetch(:issuer, 'legion')
|
|
36
|
+
|
|
37
|
+
validate_algorithm!(algorithm)
|
|
38
|
+
|
|
39
|
+
decode_opts = {
|
|
40
|
+
algorithm: algorithm,
|
|
41
|
+
verify_expiration: verify_expiration,
|
|
42
|
+
verify_iss: verify_issuer
|
|
43
|
+
}
|
|
44
|
+
decode_opts[:iss] = issuer if verify_issuer
|
|
45
|
+
|
|
46
|
+
payload, _header = ::JWT.decode(token, verification_key, true, decode_opts)
|
|
47
|
+
symbolize_keys(payload)
|
|
48
|
+
rescue ::JWT::ExpiredSignature
|
|
49
|
+
raise ExpiredTokenError, 'token has expired'
|
|
50
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
51
|
+
raise InvalidTokenError, 'token signature verification failed'
|
|
52
|
+
rescue ::JWT::DecodeError => e
|
|
53
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.decode(token)
|
|
57
|
+
payload, _header = ::JWT.decode(token, nil, false)
|
|
58
|
+
symbolize_keys(payload)
|
|
59
|
+
rescue ::JWT::DecodeError => e
|
|
60
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.verify_with_jwks(token, jwks_url:, **opts)
|
|
64
|
+
header = decode_header(token)
|
|
65
|
+
kid = header['kid']
|
|
66
|
+
algorithm = header['alg'] || 'RS256'
|
|
67
|
+
|
|
68
|
+
raise InvalidTokenError, 'token header missing kid' unless kid
|
|
69
|
+
|
|
70
|
+
validate_algorithm!(algorithm)
|
|
71
|
+
|
|
72
|
+
public_key = Legion::Crypt::JwksClient.find_key(jwks_url, kid)
|
|
73
|
+
|
|
74
|
+
verify_expiration = opts.fetch(:verify_expiration, true)
|
|
75
|
+
issuers = opts[:issuers]
|
|
76
|
+
audience = opts[:audience]
|
|
77
|
+
|
|
78
|
+
decode_opts = {
|
|
79
|
+
algorithm: algorithm,
|
|
80
|
+
verify_expiration: verify_expiration
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if issuers
|
|
84
|
+
decode_opts[:verify_iss] = true
|
|
85
|
+
decode_opts[:iss] = issuers
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if audience
|
|
89
|
+
decode_opts[:verify_aud] = true
|
|
90
|
+
decode_opts[:aud] = audience
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
payload, _header = ::JWT.decode(token, public_key, true, decode_opts)
|
|
94
|
+
symbolize_keys(payload)
|
|
95
|
+
rescue ::JWT::ExpiredSignature
|
|
96
|
+
raise ExpiredTokenError, 'token has expired'
|
|
97
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
98
|
+
raise InvalidTokenError, 'token signature verification failed'
|
|
99
|
+
rescue ::JWT::InvalidIssuerError
|
|
100
|
+
raise InvalidTokenError, 'token issuer not allowed'
|
|
101
|
+
rescue ::JWT::InvalidAudError
|
|
102
|
+
raise InvalidTokenError, 'token audience mismatch'
|
|
103
|
+
rescue ::JWT::DecodeError => e
|
|
104
|
+
raise DecodeError, "failed to decode token: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.decode_header(token)
|
|
108
|
+
parts = token.to_s.split('.')
|
|
109
|
+
raise DecodeError, 'invalid token format' unless parts.size == 3
|
|
110
|
+
|
|
111
|
+
header_json = Base64.urlsafe_decode64(parts[0])
|
|
112
|
+
::JSON.parse(header_json)
|
|
113
|
+
rescue ::JSON::ParserError, ArgumentError => e
|
|
114
|
+
raise DecodeError, "failed to decode token header: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.validate_algorithm!(algorithm)
|
|
118
|
+
return if SUPPORTED_ALGORITHMS.include?(algorithm)
|
|
119
|
+
|
|
120
|
+
raise ArgumentError, "unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.join(', ')}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.symbolize_keys(hash)
|
|
124
|
+
hash.transform_keys(&:to_sym)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private_class_method :validate_algorithm!, :symbolize_keys, :decode_header
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
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
|
data/lib/legion/crypt/version.rb
CHANGED
data/lib/legion/crypt.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
require 'openssl'
|
|
2
4
|
require 'base64'
|
|
3
5
|
require 'legion/crypt/version'
|
|
4
6
|
require 'legion/crypt/settings'
|
|
5
7
|
require 'legion/crypt/cipher'
|
|
8
|
+
require 'legion/crypt/jwt'
|
|
9
|
+
require 'legion/crypt/vault_jwt_auth'
|
|
10
|
+
require 'legion/crypt/lease_manager'
|
|
6
11
|
|
|
7
12
|
module Legion
|
|
8
13
|
module Crypt
|
|
@@ -21,6 +26,7 @@ module Legion
|
|
|
21
26
|
::File.write('./legionio.key', private_key) if settings[:save_private_key]
|
|
22
27
|
|
|
23
28
|
connect_vault unless settings[:vault][:token].nil?
|
|
29
|
+
start_lease_manager
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
def settings
|
|
@@ -31,10 +37,57 @@ module Legion
|
|
|
31
37
|
end
|
|
32
38
|
end
|
|
33
39
|
|
|
40
|
+
def jwt_settings
|
|
41
|
+
settings[:jwt] || Legion::Crypt::Settings.jwt
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def issue_token(payload = {}, ttl: nil, algorithm: nil)
|
|
45
|
+
jwt = jwt_settings
|
|
46
|
+
algo = algorithm || jwt[:default_algorithm]
|
|
47
|
+
token_ttl = ttl || jwt[:default_ttl]
|
|
48
|
+
|
|
49
|
+
signing_key = algo == 'RS256' ? private_key : settings[:cluster_secret]
|
|
50
|
+
|
|
51
|
+
Legion::Crypt::JWT.issue(payload, signing_key: signing_key, algorithm: algo, ttl: token_ttl,
|
|
52
|
+
issuer: jwt[:issuer])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def verify_token(token, algorithm: nil)
|
|
56
|
+
jwt = jwt_settings
|
|
57
|
+
algo = algorithm || jwt[:default_algorithm]
|
|
58
|
+
|
|
59
|
+
verification_key = algo == 'RS256' ? OpenSSL::PKey::RSA.new(public_key) : settings[:cluster_secret]
|
|
60
|
+
|
|
61
|
+
Legion::Crypt::JWT.verify(token, verification_key: verification_key, algorithm: algo,
|
|
62
|
+
verify_expiration: jwt[:verify_expiration],
|
|
63
|
+
verify_issuer: jwt[:verify_issuer],
|
|
64
|
+
issuer: jwt[:issuer])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def verify_external_token(token, jwks_url:, **)
|
|
68
|
+
Legion::Crypt::JWT.verify_with_jwks(token, jwks_url: jwks_url, **)
|
|
69
|
+
end
|
|
70
|
+
|
|
34
71
|
def shutdown
|
|
72
|
+
Legion::Crypt::LeaseManager.instance.shutdown
|
|
35
73
|
shutdown_renewer
|
|
36
74
|
close_sessions
|
|
37
75
|
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def start_lease_manager
|
|
80
|
+
leases = settings.dig(:vault, :leases) || {}
|
|
81
|
+
return if leases.empty?
|
|
82
|
+
return unless settings.dig(:vault, :connected)
|
|
83
|
+
|
|
84
|
+
lease_manager = Legion::Crypt::LeaseManager.instance
|
|
85
|
+
lease_manager.start(leases)
|
|
86
|
+
lease_manager.start_renewal_thread
|
|
87
|
+
Legion::Logging.info "LeaseManager: #{leases.size} lease(s) initialized"
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Legion::Logging.warn "LeaseManager startup failed: #{e.message}"
|
|
90
|
+
end
|
|
38
91
|
end
|
|
39
92
|
end
|
|
40
93
|
end
|