legion-crypt 1.4.28 → 1.5.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/CHANGELOG.md +29 -0
- data/Gemfile +1 -0
- data/legion-crypt.gemspec +1 -0
- data/lib/legion/crypt/attestation.rb +18 -8
- data/lib/legion/crypt/cert_rotation.rb +54 -42
- data/lib/legion/crypt/cipher.rb +106 -15
- data/lib/legion/crypt/cluster_secret.rb +41 -39
- data/lib/legion/crypt/ed25519.rb +58 -18
- data/lib/legion/crypt/erasure.rb +21 -8
- data/lib/legion/crypt/jwks_client.rb +37 -9
- data/lib/legion/crypt/jwt.rb +75 -31
- data/lib/legion/crypt/kerberos_auth.rb +23 -13
- data/lib/legion/crypt/ldap_auth.rb +12 -4
- data/lib/legion/crypt/lease_manager.rb +126 -73
- data/lib/legion/crypt/mtls.rb +14 -1
- data/lib/legion/crypt/partition_keys.rb +15 -5
- data/lib/legion/crypt/settings.rb +18 -12
- data/lib/legion/crypt/spiffe/identity_helpers.rb +18 -11
- data/lib/legion/crypt/spiffe/svid_rotation.rb +23 -33
- data/lib/legion/crypt/spiffe/workload_api_client.rb +61 -17
- data/lib/legion/crypt/spiffe.rb +18 -4
- data/lib/legion/crypt/tls.rb +14 -10
- data/lib/legion/crypt/token_renewer.rb +29 -26
- data/lib/legion/crypt/vault.rb +57 -45
- data/lib/legion/crypt/vault_cluster.rb +35 -17
- data/lib/legion/crypt/vault_jwt_auth.rb +17 -4
- data/lib/legion/crypt/vault_kerberos_auth.rb +11 -1
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +69 -32
- data/lib/legion/logging/helper.rb +98 -0
- data/lib/legion/logging.rb +58 -0
- metadata +17 -1
data/lib/legion/crypt/ed25519.rb
CHANGED
|
@@ -1,58 +1,83 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'ed25519'
|
|
4
|
+
require 'legion/logging/helper'
|
|
4
5
|
|
|
5
6
|
module Legion
|
|
6
7
|
module Crypt
|
|
7
8
|
module Ed25519
|
|
9
|
+
extend Legion::Logging::Helper
|
|
10
|
+
|
|
8
11
|
class << self
|
|
9
12
|
def generate_keypair
|
|
10
13
|
signing_key = ::Ed25519::SigningKey.generate
|
|
11
|
-
|
|
14
|
+
log.info 'Ed25519 keypair generated'
|
|
12
15
|
{
|
|
13
16
|
private_key: signing_key.to_bytes,
|
|
14
17
|
public_key: signing_key.verify_key.to_bytes,
|
|
15
18
|
public_key_hex: signing_key.verify_key.to_bytes.unpack1('H*')
|
|
16
19
|
}
|
|
20
|
+
rescue StandardError => e
|
|
21
|
+
handle_exception(e, level: :error, operation: 'crypt.ed25519.generate_keypair')
|
|
22
|
+
raise
|
|
17
23
|
end
|
|
18
24
|
|
|
19
25
|
def sign(message, private_key_bytes)
|
|
20
26
|
signing_key = ::Ed25519::SigningKey.new(private_key_bytes)
|
|
21
27
|
result = signing_key.sign(message)
|
|
22
|
-
|
|
28
|
+
log.debug 'Ed25519 sign complete'
|
|
23
29
|
result
|
|
30
|
+
rescue StandardError => e
|
|
31
|
+
handle_exception(e, level: :error, operation: 'crypt.ed25519.sign')
|
|
32
|
+
raise
|
|
24
33
|
end
|
|
25
34
|
|
|
26
35
|
def verify(message, signature, public_key_bytes)
|
|
27
36
|
verify_key = ::Ed25519::VerifyKey.new(public_key_bytes)
|
|
28
37
|
verify_key.verify(signature, message)
|
|
29
|
-
|
|
38
|
+
log.debug 'Ed25519 verify success'
|
|
30
39
|
true
|
|
31
40
|
rescue ::Ed25519::VerifyError => e
|
|
32
|
-
|
|
41
|
+
handle_exception(e, level: :debug, operation: 'crypt.ed25519.verify.signature_mismatch')
|
|
42
|
+
log.warn 'Ed25519 signature verification failed'
|
|
33
43
|
false
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
handle_exception(e, level: :error, operation: 'crypt.ed25519.verify')
|
|
46
|
+
raise
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
def store_keypair(agent_id:, keypair: nil)
|
|
37
50
|
keypair ||= generate_keypair
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
Legion::
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
if Legion::Crypt.respond_to?(:write)
|
|
52
|
+
log.info "Ed25519 storing keypair for agent #{agent_id}"
|
|
53
|
+
Legion::Crypt.write(
|
|
54
|
+
vault_key_path(agent_id),
|
|
55
|
+
private_key: keypair[:private_key].unpack1('H*'),
|
|
56
|
+
public_key: keypair[:public_key_hex]
|
|
57
|
+
)
|
|
58
|
+
else
|
|
59
|
+
log.warn "Ed25519 keypair generated for agent #{agent_id} but Vault is unavailable"
|
|
45
60
|
end
|
|
46
61
|
keypair
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
handle_exception(e, level: :error, operation: 'crypt.ed25519.store_keypair', agent_id: agent_id)
|
|
64
|
+
raise
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
def load_private_key(agent_id:)
|
|
50
|
-
|
|
51
|
-
Legion::
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
log.debug "Ed25519 loading private key for agent #{agent_id}"
|
|
69
|
+
return nil unless Legion::Crypt.respond_to?(:get)
|
|
70
|
+
|
|
71
|
+
data = Legion::Crypt.get(vault_key_path(agent_id))
|
|
72
|
+
if data&.dig(:private_key)
|
|
73
|
+
log.info "Ed25519 private key loaded for agent #{agent_id}"
|
|
74
|
+
[data[:private_key]].pack('H*')
|
|
75
|
+
else
|
|
76
|
+
log.warn "Ed25519 private key missing for agent #{agent_id}"
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
54
79
|
rescue StandardError => e
|
|
55
|
-
|
|
80
|
+
handle_exception(e, level: :warn, operation: 'crypt.ed25519.load_private_key', agent_id: agent_id)
|
|
56
81
|
nil
|
|
57
82
|
end
|
|
58
83
|
|
|
@@ -62,9 +87,24 @@ module Legion
|
|
|
62
87
|
begin
|
|
63
88
|
Legion::Settings[:crypt][:ed25519][:vault_key_prefix]
|
|
64
89
|
rescue StandardError => e
|
|
65
|
-
|
|
90
|
+
handle_exception(e, level: :debug, operation: 'crypt.ed25519.key_prefix')
|
|
66
91
|
nil
|
|
67
|
-
end || '
|
|
92
|
+
end || 'keys'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def vault_key_path(agent_id)
|
|
96
|
+
normalize_kv_path("#{key_prefix}/#{agent_id}")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_kv_path(path)
|
|
100
|
+
kv_path = Legion::Settings.dig(:crypt, :vault, :kv_path)
|
|
101
|
+
return path if kv_path.nil? || kv_path.empty?
|
|
102
|
+
|
|
103
|
+
normalized = path.sub(%r{\Asecret/data/#{Regexp.escape(kv_path)}/}, '')
|
|
104
|
+
normalized.sub(%r{\A#{Regexp.escape(kv_path)}/}, '')
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
handle_exception(e, level: :debug, operation: 'crypt.ed25519.normalize_kv_path')
|
|
107
|
+
path
|
|
68
108
|
end
|
|
69
109
|
end
|
|
70
110
|
end
|
data/lib/legion/crypt/erasure.rb
CHANGED
|
@@ -1,29 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Crypt
|
|
5
7
|
module Erasure
|
|
8
|
+
extend Legion::Logging::Helper
|
|
9
|
+
|
|
6
10
|
class << self
|
|
7
11
|
def erase_tenant(tenant_id:)
|
|
8
12
|
key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
log.info "[crypt] Erasing tenant #{tenant_id}"
|
|
15
|
+
if Legion::Crypt.respond_to?(:delete)
|
|
16
|
+
Legion::Crypt.delete(key_path)
|
|
17
|
+
elsif defined?(Legion::Crypt::Vault)
|
|
18
|
+
delete_vault_key(key_path)
|
|
19
|
+
end
|
|
11
20
|
Legion::Events.emit('crypt.tenant_erased', { tenant_id: tenant_id, erased_at: Time.now.utc }) if defined?(Legion::Events)
|
|
12
|
-
|
|
21
|
+
log.warn "[crypt] Tenant #{tenant_id} cryptographically erased"
|
|
13
22
|
|
|
14
23
|
{ erased: true, tenant_id: tenant_id, path: key_path }
|
|
15
24
|
rescue StandardError => e
|
|
16
|
-
|
|
25
|
+
handle_exception(e, level: :error, operation: 'crypt.erasure.erase_tenant', tenant_id: tenant_id)
|
|
17
26
|
{ erased: false, tenant_id: tenant_id, error: e.message }
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def verify_erasure(tenant_id:)
|
|
21
30
|
key_path = "#{tenant_prefix}/#{tenant_id}/master_key"
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
raise 'Legion::Crypt.read is unavailable' unless Legion::Crypt.respond_to?(:read)
|
|
32
|
+
|
|
33
|
+
data = Legion::Crypt.read(key_path, nil)
|
|
34
|
+
erased = data.nil?
|
|
35
|
+
log.info "Tenant erasure verification completed for #{tenant_id}: erased=#{erased}"
|
|
36
|
+
{ erased: erased, tenant_id: tenant_id }
|
|
24
37
|
rescue StandardError => e
|
|
25
|
-
|
|
26
|
-
{ erased:
|
|
38
|
+
handle_exception(e, level: :warn, operation: 'crypt.erasure.verify_erasure', tenant_id: tenant_id)
|
|
39
|
+
{ erased: false, tenant_id: tenant_id, error: e.message }
|
|
27
40
|
end
|
|
28
41
|
|
|
29
42
|
private
|
|
@@ -36,7 +49,7 @@ module Legion
|
|
|
36
49
|
begin
|
|
37
50
|
Legion::Settings[:crypt][:partition_keys][:vault_tenant_prefix]
|
|
38
51
|
rescue StandardError => e
|
|
39
|
-
|
|
52
|
+
handle_exception(e, level: :debug, operation: 'crypt.erasure.tenant_prefix')
|
|
40
53
|
nil
|
|
41
54
|
end || 'secret/data/legion/tenants'
|
|
42
55
|
end
|
|
@@ -5,6 +5,7 @@ require 'uri'
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'openssl'
|
|
7
7
|
require 'jwt'
|
|
8
|
+
require 'legion/logging/helper'
|
|
8
9
|
|
|
9
10
|
module Legion
|
|
10
11
|
module Crypt
|
|
@@ -12,33 +13,40 @@ module Legion
|
|
|
12
13
|
CACHE_TTL = 3600
|
|
13
14
|
|
|
14
15
|
@cache = {}
|
|
15
|
-
@
|
|
16
|
+
@cache_mutex = Mutex.new
|
|
17
|
+
@locks = {}
|
|
18
|
+
@locks_mutex = Mutex.new
|
|
16
19
|
|
|
17
20
|
class << self
|
|
21
|
+
include Legion::Logging::Helper
|
|
22
|
+
|
|
18
23
|
def fetch_keys(jwks_url)
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
with_url_lock(jwks_url) do
|
|
25
|
+
log.debug "JWKS fetch: #{jwks_url}"
|
|
21
26
|
response = http_get(jwks_url)
|
|
22
27
|
jwks_data = parse_response(response)
|
|
23
28
|
keys = parse_jwks(jwks_data)
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
cache_write(jwks_url, keys)
|
|
31
|
+
log.info "JWKS fetched url=#{jwks_url} keys=#{keys.size}"
|
|
26
32
|
keys
|
|
27
33
|
end
|
|
28
34
|
rescue StandardError => e
|
|
29
|
-
|
|
35
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwks.fetch_keys', jwks_url: jwks_url)
|
|
30
36
|
raise
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def find_key(jwks_url, kid)
|
|
34
|
-
cached =
|
|
40
|
+
cached = cache_read(jwks_url)
|
|
35
41
|
|
|
36
42
|
if cached && !expired?(cached[:fetched_at])
|
|
37
43
|
key = cached[:keys][kid]
|
|
38
44
|
if key
|
|
39
|
-
|
|
45
|
+
log.debug "JWKS cache hit: kid=#{kid}"
|
|
40
46
|
return key
|
|
41
47
|
end
|
|
48
|
+
|
|
49
|
+
log.debug "JWKS cache miss for kid=#{kid}; refreshing keys"
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
keys = fetch_keys(jwks_url)
|
|
@@ -49,17 +57,31 @@ module Legion
|
|
|
49
57
|
end
|
|
50
58
|
|
|
51
59
|
def clear_cache
|
|
52
|
-
@
|
|
60
|
+
@cache_mutex.synchronize { @cache = {} }
|
|
61
|
+
@locks_mutex.synchronize { @locks = {} }
|
|
62
|
+
log.info 'JWKS cache cleared'
|
|
53
63
|
end
|
|
54
64
|
|
|
55
65
|
private
|
|
56
66
|
|
|
67
|
+
def cache_read(jwks_url)
|
|
68
|
+
@cache_mutex.synchronize { @cache[jwks_url] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def cache_write(jwks_url, keys)
|
|
72
|
+
@cache_mutex.synchronize do
|
|
73
|
+
@cache[jwks_url] = { keys: keys, fetched_at: Time.now }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
57
77
|
def expired?(fetched_at)
|
|
58
78
|
Time.now - fetched_at > CACHE_TTL
|
|
59
79
|
end
|
|
60
80
|
|
|
61
81
|
def http_get(url)
|
|
62
82
|
uri = URI.parse(url)
|
|
83
|
+
raise Legion::Crypt::JWT::Error, 'failed to fetch JWKS: HTTPS is required' unless uri.scheme == 'https'
|
|
84
|
+
|
|
63
85
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
64
86
|
http.use_ssl = uri.scheme == 'https'
|
|
65
87
|
http.open_timeout = 10
|
|
@@ -83,6 +105,7 @@ module Legion
|
|
|
83
105
|
|
|
84
106
|
parsed
|
|
85
107
|
rescue ::JSON::ParserError => e
|
|
108
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwks.parse_response')
|
|
86
109
|
raise Legion::Crypt::JWT::Error, "invalid JWKS response: #{e.message}"
|
|
87
110
|
end
|
|
88
111
|
|
|
@@ -96,12 +119,17 @@ module Legion
|
|
|
96
119
|
jwk = ::JWT::JWK.new(jwk_hash)
|
|
97
120
|
keys[kid] = jwk.public_key
|
|
98
121
|
rescue StandardError => e
|
|
99
|
-
|
|
122
|
+
handle_exception(e, level: :debug, operation: 'crypt.jwks.parse_jwks', kid: kid)
|
|
100
123
|
next
|
|
101
124
|
end
|
|
102
125
|
|
|
103
126
|
keys
|
|
104
127
|
end
|
|
128
|
+
|
|
129
|
+
def with_url_lock(jwks_url, &)
|
|
130
|
+
lock = @locks_mutex.synchronize { @locks[jwks_url] ||= Mutex.new }
|
|
131
|
+
lock.synchronize(&)
|
|
132
|
+
end
|
|
105
133
|
end
|
|
106
134
|
end
|
|
107
135
|
end
|
data/lib/legion/crypt/jwt.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'jwt'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require 'legion/logging/helper'
|
|
5
6
|
require 'legion/crypt/jwks_client'
|
|
6
7
|
|
|
7
8
|
module Legion
|
|
@@ -14,20 +15,25 @@ module Legion
|
|
|
14
15
|
|
|
15
16
|
SUPPORTED_ALGORITHMS = %w[HS256 RS256].freeze
|
|
16
17
|
|
|
18
|
+
extend Legion::Logging::Helper
|
|
19
|
+
|
|
17
20
|
def self.issue(payload, signing_key:, algorithm: 'HS256', ttl: 3600, issuer: 'legion')
|
|
18
21
|
validate_algorithm!(algorithm)
|
|
19
22
|
|
|
20
23
|
now = Time.now.to_i
|
|
21
|
-
claims =
|
|
24
|
+
claims = sanitize_payload(payload).merge(
|
|
22
25
|
iss: issuer,
|
|
23
26
|
iat: now,
|
|
24
27
|
exp: now + ttl,
|
|
25
28
|
jti: SecureRandom.uuid
|
|
26
|
-
|
|
29
|
+
)
|
|
27
30
|
|
|
28
31
|
token = ::JWT.encode(claims, signing_key, algorithm)
|
|
29
|
-
|
|
32
|
+
log.info "JWT issued: sub=#{claims[:sub]}, exp=#{Time.at(claims[:exp]).utc.iso8601}, alg=#{algorithm}"
|
|
30
33
|
token
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
handle_exception(e, level: :error, operation: 'crypt.jwt.issue', algorithm: algorithm, issuer: issuer)
|
|
36
|
+
raise
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def self.verify(token, verification_key:, **opts)
|
|
@@ -47,24 +53,34 @@ module Legion
|
|
|
47
53
|
|
|
48
54
|
payload, _header = ::JWT.decode(token, verification_key, true, decode_opts)
|
|
49
55
|
result = symbolize_keys(payload)
|
|
50
|
-
|
|
56
|
+
log.debug "JWT verify success: sub=#{result[:sub]}, jti=#{result[:jti]}"
|
|
51
57
|
result
|
|
52
|
-
rescue ::JWT::ExpiredSignature
|
|
53
|
-
|
|
58
|
+
rescue ::JWT::ExpiredSignature => e
|
|
59
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.expired', algorithm: algorithm)
|
|
60
|
+
log.warn 'JWT verify failed: token has expired'
|
|
54
61
|
raise ExpiredTokenError, 'token has expired'
|
|
55
|
-
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
56
|
-
|
|
62
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm => e
|
|
63
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.signature', algorithm: algorithm)
|
|
64
|
+
log.warn 'JWT verify failed: signature verification failed'
|
|
57
65
|
raise InvalidTokenError, 'token signature verification failed'
|
|
58
66
|
rescue ::JWT::DecodeError => e
|
|
59
|
-
|
|
67
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify.decode', algorithm: algorithm)
|
|
68
|
+
log.warn "JWT verify failed: #{e.message}"
|
|
60
69
|
raise DecodeError, "failed to decode token: #{e.message}"
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
handle_exception(e, level: :error, operation: 'crypt.jwt.verify', algorithm: algorithm)
|
|
72
|
+
raise
|
|
61
73
|
end
|
|
62
74
|
|
|
63
75
|
def self.decode(token)
|
|
64
76
|
payload, _header = ::JWT.decode(token, nil, false)
|
|
65
77
|
symbolize_keys(payload)
|
|
66
78
|
rescue ::JWT::DecodeError => e
|
|
79
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.decode')
|
|
67
80
|
raise DecodeError, "failed to decode token: #{e.message}"
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
handle_exception(e, level: :error, operation: 'crypt.jwt.decode')
|
|
83
|
+
raise
|
|
68
84
|
end
|
|
69
85
|
|
|
70
86
|
def self.verify_with_jwks(token, jwks_url:, **opts)
|
|
@@ -81,41 +97,44 @@ module Legion
|
|
|
81
97
|
verify_expiration = opts.fetch(:verify_expiration, true)
|
|
82
98
|
issuers = opts[:issuers]
|
|
83
99
|
audience = opts[:audience]
|
|
100
|
+
validate_external_requirements!(issuers: issuers, audience: audience)
|
|
84
101
|
|
|
85
102
|
decode_opts = {
|
|
86
103
|
algorithm: algorithm,
|
|
87
|
-
verify_expiration: verify_expiration
|
|
104
|
+
verify_expiration: verify_expiration,
|
|
105
|
+
verify_iss: true,
|
|
106
|
+
iss: issuers,
|
|
107
|
+
verify_aud: true,
|
|
108
|
+
aud: audience
|
|
88
109
|
}
|
|
89
110
|
|
|
90
|
-
if issuers
|
|
91
|
-
decode_opts[:verify_iss] = true
|
|
92
|
-
decode_opts[:iss] = issuers
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
if audience
|
|
96
|
-
decode_opts[:verify_aud] = true
|
|
97
|
-
decode_opts[:aud] = audience
|
|
98
|
-
end
|
|
99
|
-
|
|
100
111
|
payload, _header = ::JWT.decode(token, public_key, true, decode_opts)
|
|
101
112
|
result = symbolize_keys(payload)
|
|
102
|
-
|
|
113
|
+
log.info "JWT JWKS verify success: sub=#{result[:sub]}, kid=#{kid}"
|
|
103
114
|
result
|
|
104
|
-
rescue ::JWT::ExpiredSignature
|
|
105
|
-
|
|
115
|
+
rescue ::JWT::ExpiredSignature => e
|
|
116
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.expired', jwks_url: jwks_url, kid: kid)
|
|
117
|
+
log.warn "JWT JWKS verify failed: token has expired, kid=#{kid}"
|
|
106
118
|
raise ExpiredTokenError, 'token has expired'
|
|
107
|
-
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm
|
|
108
|
-
|
|
119
|
+
rescue ::JWT::VerificationError, ::JWT::IncorrectAlgorithm => e
|
|
120
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.signature', jwks_url: jwks_url, kid: kid)
|
|
121
|
+
log.warn "JWT JWKS verify failed: signature verification failed, kid=#{kid}"
|
|
109
122
|
raise InvalidTokenError, 'token signature verification failed'
|
|
110
|
-
rescue ::JWT::InvalidIssuerError
|
|
111
|
-
|
|
123
|
+
rescue ::JWT::InvalidIssuerError => e
|
|
124
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.issuer', jwks_url: jwks_url, kid: kid)
|
|
125
|
+
log.warn "JWT JWKS verify failed: issuer not allowed, kid=#{kid}"
|
|
112
126
|
raise InvalidTokenError, 'token issuer not allowed'
|
|
113
|
-
rescue ::JWT::InvalidAudError
|
|
114
|
-
|
|
127
|
+
rescue ::JWT::InvalidAudError => e
|
|
128
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.audience', jwks_url: jwks_url, kid: kid)
|
|
129
|
+
log.warn "JWT JWKS verify failed: audience mismatch, kid=#{kid}"
|
|
115
130
|
raise InvalidTokenError, 'token audience mismatch'
|
|
116
131
|
rescue ::JWT::DecodeError => e
|
|
117
|
-
|
|
132
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.verify_with_jwks.decode', jwks_url: jwks_url, kid: kid)
|
|
133
|
+
log.warn "JWT JWKS verify failed: #{e.message}, kid=#{kid}"
|
|
118
134
|
raise DecodeError, "failed to decode token: #{e.message}"
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
handle_exception(e, level: :error, operation: 'crypt.jwt.verify_with_jwks', jwks_url: jwks_url, kid: kid)
|
|
137
|
+
raise
|
|
119
138
|
end
|
|
120
139
|
|
|
121
140
|
def self.decode_header(token)
|
|
@@ -125,7 +144,11 @@ module Legion
|
|
|
125
144
|
header_json = Base64.urlsafe_decode64(parts[0])
|
|
126
145
|
::JSON.parse(header_json)
|
|
127
146
|
rescue ::JSON::ParserError, ArgumentError => e
|
|
147
|
+
handle_exception(e, level: :warn, operation: 'crypt.jwt.decode_header')
|
|
128
148
|
raise DecodeError, "failed to decode token header: #{e.message}"
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
handle_exception(e, level: :error, operation: 'crypt.jwt.decode_header')
|
|
151
|
+
raise
|
|
129
152
|
end
|
|
130
153
|
|
|
131
154
|
def self.validate_algorithm!(algorithm)
|
|
@@ -138,7 +161,28 @@ module Legion
|
|
|
138
161
|
hash.transform_keys(&:to_sym)
|
|
139
162
|
end
|
|
140
163
|
|
|
141
|
-
|
|
164
|
+
def self.sanitize_payload(payload)
|
|
165
|
+
payload.each_with_object({}) do |(key, value), sanitized|
|
|
166
|
+
next if %w[iss iat exp jti].include?(key.to_s)
|
|
167
|
+
|
|
168
|
+
sanitized[key] = value
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.validate_external_requirements!(issuers:, audience:)
|
|
173
|
+
raise ArgumentError, 'issuers is required for JWKS verification' if blank_external_requirement?(issuers)
|
|
174
|
+
raise ArgumentError, 'audience is required for JWKS verification' if blank_external_requirement?(audience)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.blank_external_requirement?(value)
|
|
178
|
+
return true if value.nil?
|
|
179
|
+
return true if value.respond_to?(:empty?) && value.empty?
|
|
180
|
+
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private_class_method :validate_algorithm!, :symbolize_keys, :decode_header, :sanitize_payload,
|
|
185
|
+
:validate_external_requirements!, :blank_external_requirement?
|
|
142
186
|
end
|
|
143
187
|
end
|
|
144
188
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Crypt
|
|
5
7
|
module KerberosAuth
|
|
@@ -9,6 +11,7 @@ module Legion
|
|
|
9
11
|
DEFAULT_AUTH_PATH = 'auth/kerberos/login'
|
|
10
12
|
|
|
11
13
|
@kerberos_principal = nil
|
|
14
|
+
extend Legion::Logging::Helper
|
|
12
15
|
|
|
13
16
|
class << self
|
|
14
17
|
attr_reader :kerberos_principal
|
|
@@ -17,19 +20,21 @@ module Legion
|
|
|
17
20
|
def self.login(vault_client:, service_principal:, auth_path: DEFAULT_AUTH_PATH)
|
|
18
21
|
raise GemMissingError, 'lex-kerberos gem is required for Kerberos auth' unless spnego_available?
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
log.info "KerberosAuth login requested auth_path=#{auth_path}"
|
|
24
|
+
log.debug("KerberosAuth: login: SPN=#{service_principal}, auth_path=#{auth_path}")
|
|
21
25
|
addr = vault_client.respond_to?(:address) ? vault_client.address : 'n/a'
|
|
22
26
|
ns = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a'
|
|
23
|
-
|
|
27
|
+
log.debug("KerberosAuth: login: vault_client.address=#{addr}, namespace=#{ns}")
|
|
24
28
|
|
|
25
29
|
@kerberos_principal = nil
|
|
26
30
|
token = obtain_token(service_principal)
|
|
27
|
-
|
|
31
|
+
log.debug("KerberosAuth: login: SPNEGO token obtained (#{token.length} chars)")
|
|
28
32
|
|
|
29
33
|
result = exchange_token(vault_client, token, auth_path)
|
|
30
34
|
@kerberos_principal = result[:metadata]&.dig('username') || result[:metadata]&.dig(:username)
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
log.debug("KerberosAuth: login: authenticated as #{@kerberos_principal.inspect}, policies=#{result[:policies].inspect}")
|
|
36
|
+
log.debug("KerberosAuth: login: renewable=#{result[:renewable]}, ttl=#{result[:lease_duration]}s")
|
|
37
|
+
log.info "KerberosAuth login success principal=#{@kerberos_principal || 'unknown'} auth_path=#{auth_path}"
|
|
33
38
|
result
|
|
34
39
|
end
|
|
35
40
|
|
|
@@ -39,7 +44,8 @@ module Legion
|
|
|
39
44
|
@spnego_available = begin
|
|
40
45
|
require 'legion/extensions/kerberos/helpers/spnego'
|
|
41
46
|
true
|
|
42
|
-
rescue LoadError
|
|
47
|
+
rescue LoadError => e
|
|
48
|
+
handle_exception(e, level: :debug, operation: 'crypt.kerberos_auth.spnego_available')
|
|
43
49
|
# check if constant was already defined (e.g. stubbed in tests or loaded via another path)
|
|
44
50
|
defined?(Legion::Extensions::Kerberos::Helpers::Spnego) ? true : false
|
|
45
51
|
end
|
|
@@ -50,11 +56,6 @@ module Legion
|
|
|
50
56
|
@kerberos_principal = nil
|
|
51
57
|
end
|
|
52
58
|
|
|
53
|
-
def self.log_debug(message)
|
|
54
|
-
Legion::Logging.debug("KerberosAuth: #{message}") if defined?(Legion::Logging)
|
|
55
|
-
end
|
|
56
|
-
private_class_method :log_debug
|
|
57
|
-
|
|
58
59
|
class << self
|
|
59
60
|
private
|
|
60
61
|
|
|
@@ -63,7 +64,11 @@ module Legion
|
|
|
63
64
|
result = helper.obtain_spnego_token(service_principal: service_principal)
|
|
64
65
|
raise AuthError, "SPNEGO token acquisition failed: #{result[:error]}" unless result[:success]
|
|
65
66
|
|
|
67
|
+
log.info 'KerberosAuth obtained SPNEGO token'
|
|
66
68
|
result[:token]
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
handle_exception(e, level: :error, operation: 'crypt.kerberos_auth.obtain_token', auth_method: 'kerberos')
|
|
71
|
+
raise
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def exchange_token(vault_client, spnego_token, auth_path)
|
|
@@ -72,7 +77,8 @@ module Legion
|
|
|
72
77
|
|
|
73
78
|
# The Vault Kerberos plugin reads the SPNEGO token from the HTTP
|
|
74
79
|
# Authorization header, not the JSON body.
|
|
75
|
-
|
|
80
|
+
namespace = vault_client.respond_to?(:namespace) ? vault_client.namespace.inspect : 'n/a'
|
|
81
|
+
log.debug("KerberosAuth: exchange_token: PUT /v1/#{auth_path} (namespace=#{namespace})")
|
|
76
82
|
json = vault_client.put(
|
|
77
83
|
"/v1/#{auth_path}",
|
|
78
84
|
'{}',
|
|
@@ -90,8 +96,12 @@ module Legion
|
|
|
90
96
|
metadata: auth.metadata
|
|
91
97
|
}
|
|
92
98
|
rescue ::Vault::HTTPClientError => e
|
|
93
|
-
|
|
99
|
+
handle_exception(e, level: :warn, operation: 'crypt.kerberos_auth.exchange_token', auth_path: auth_path)
|
|
100
|
+
log.debug("KerberosAuth: exchange_token: HTTP error: #{e.message}")
|
|
94
101
|
raise AuthError, "Vault Kerberos auth failed: #{e.message}"
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
handle_exception(e, level: :error, operation: 'crypt.kerberos_auth.exchange_token', auth_path: auth_path)
|
|
104
|
+
raise
|
|
95
105
|
end
|
|
96
106
|
end
|
|
97
107
|
end
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/logging/helper'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Crypt
|
|
5
7
|
module LdapAuth
|
|
8
|
+
include Legion::Logging::Helper
|
|
9
|
+
|
|
6
10
|
def ldap_login(cluster_name:, username:, password:)
|
|
7
11
|
cluster_name = cluster_name.to_sym
|
|
12
|
+
log.info "LDAP login requested user=#{username} cluster=#{cluster_name}"
|
|
8
13
|
client = vault_client(cluster_name)
|
|
9
14
|
secret = client.logical.write("auth/ldap/login/#{username}", password: password)
|
|
10
15
|
auth = secret.auth
|
|
@@ -12,13 +17,14 @@ module Legion
|
|
|
12
17
|
|
|
13
18
|
clusters[cluster_name][:token] = token
|
|
14
19
|
clusters[cluster_name][:connected] = true
|
|
15
|
-
|
|
20
|
+
client.token = token if client.respond_to?(:token=)
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
log.info "LDAP login success: user=#{username}, cluster=#{cluster_name}"
|
|
18
23
|
{ token: token, lease_duration: auth.lease_duration,
|
|
19
24
|
renewable: auth.renewable?, policies: auth.policies }
|
|
20
25
|
rescue StandardError => e
|
|
21
|
-
|
|
26
|
+
handle_exception(e, level: :error, operation: 'crypt.ldap_auth.ldap_login', cluster_name: cluster_name, username: username)
|
|
27
|
+
log.error "LDAP login failed: user=#{username}, cluster=#{cluster_name}: #{e.message}"
|
|
22
28
|
raise
|
|
23
29
|
end
|
|
24
30
|
|
|
@@ -29,9 +35,11 @@ module Legion
|
|
|
29
35
|
|
|
30
36
|
results[name] = ldap_login(cluster_name: name, username: username, password: password)
|
|
31
37
|
rescue StandardError => e
|
|
32
|
-
|
|
38
|
+
handle_exception(e, level: :warn, operation: 'crypt.ldap_auth.ldap_login_all', cluster_name: name, username: username)
|
|
39
|
+
log.warn("Legion::Crypt::LdapAuth#ldap_login_all cluster=#{name} failed: #{e.message}")
|
|
33
40
|
results[name] = { error: e.message }
|
|
34
41
|
end
|
|
42
|
+
log.info "LDAP login_all complete successes=#{results.count { |_, result| result.is_a?(Hash) && !result.key?(:error) }} attempted=#{results.size}"
|
|
35
43
|
results
|
|
36
44
|
end
|
|
37
45
|
end
|