legion-crypt 1.4.23 → 1.4.25
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/AGENTS.md +38 -0
- data/CHANGELOG.md +25 -0
- data/lib/legion/crypt/lease_manager.rb +35 -0
- data/lib/legion/crypt/settings.rb +10 -0
- data/lib/legion/crypt/spiffe/identity_helpers.rb +130 -0
- data/lib/legion/crypt/spiffe/svid_rotation.rb +157 -0
- data/lib/legion/crypt/spiffe/workload_api_client.rb +391 -0
- data/lib/legion/crypt/spiffe.rb +139 -0
- data/lib/legion/crypt/version.rb +1 -1
- data/lib/legion/crypt.rb +36 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37fe457155877f451f702c4de046bcf9bd5cabd8a86d9bbf51cd3c4484ab10ce
|
|
4
|
+
data.tar.gz: 642f9ddda00b7566be3001a847687bded7fc196a0e2330936f909b4753cb9eb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b98e831598f7a901502b59950e2ae46f4393ec2fb6fab9f2d576295905ebdad31187c170fdc70f304ce4ef1d3fd14470a0001b24000869c6e96ee3ab8ee269a8
|
|
7
|
+
data.tar.gz: 593f3909d5374c15cda3f69ae1697a59bb1b3c9a5f713aa91fde5d77656bb7a8b7b2062e618652afe7554a518e6251b219d8c3831bc9c82ddf5a6784cca1f56b
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# legion-crypt Agent Notes
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
`legion-crypt` handles cryptography and secret workflows for Legion: cipher ops, Vault integration, JWT/JWKS verification, key lifecycle, mTLS, and lease/token renewers.
|
|
6
|
+
|
|
7
|
+
## Fast Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle install
|
|
11
|
+
bundle exec rspec
|
|
12
|
+
bundle exec rubocop
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Primary Entry Points
|
|
16
|
+
|
|
17
|
+
- `lib/legion/crypt.rb`
|
|
18
|
+
- `lib/legion/crypt/cipher.rb`
|
|
19
|
+
- `lib/legion/crypt/jwt.rb`
|
|
20
|
+
- `lib/legion/crypt/jwks_client.rb`
|
|
21
|
+
- `lib/legion/crypt/vault.rb`
|
|
22
|
+
- `lib/legion/crypt/lease_manager.rb`
|
|
23
|
+
- `lib/legion/crypt/token_renewer.rb`
|
|
24
|
+
- `lib/legion/crypt/mtls.rb`
|
|
25
|
+
|
|
26
|
+
## Guardrails
|
|
27
|
+
|
|
28
|
+
- Treat all changes as security-sensitive. Never log secrets, tokens, private keys, or decrypted plaintext.
|
|
29
|
+
- Preserve JWT behavior across HS256/RS256 and external JWKS validation.
|
|
30
|
+
- Keep Vault-dependent logic optional and safely guarded for environments without Vault.
|
|
31
|
+
- Background renewal/rotation threads must stop cleanly on shutdown and handle failure with bounded retry.
|
|
32
|
+
- Maintain compatibility for Kerberos, LDAP, and JWT Vault auth paths.
|
|
33
|
+
- Cryptographic defaults and key lifecycle behavior are contract-sensitive; change only with test coverage.
|
|
34
|
+
|
|
35
|
+
## Validation
|
|
36
|
+
|
|
37
|
+
- Run targeted specs for changed auth/crypto paths first.
|
|
38
|
+
- Before handoff, run full `bundle exec rspec` and `bundle exec rubocop`.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Legion::Crypt
|
|
2
2
|
|
|
3
|
+
## [1.4.25] - 2026-03-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- `kv_client` and `logical_client` now route through the default cluster `Vault::Client` when multi-cluster is configured, preventing 403 errors caused by the un-initialized global `::Vault` singleton (closes #1)
|
|
7
|
+
- `WorkloadApiClient#decode_varint`: returns `[value, bytes_consumed]` correctly; previous implementation returned `[value, start_pos]` causing the protobuf field scanner to never advance past the first tag, breaking `extract_proto_field` for any non-empty input
|
|
8
|
+
- `WorkloadApiClient#self_signed_fallback`: Subject CN is now a plain string (`legion-fallback-svid`) instead of the full `spiffe://` URI, preventing `TypeError: no implicit conversion of nil into String` from `OpenSSL::X509::Name.parse` on Ruby 3.4
|
|
9
|
+
- `spiffe_identity_helpers_spec.rb`: test cert helper uses plain CN for the same reason
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Specs for `kv_client`/`logical_client` routing: 20 examples covering multi-cluster path (cluster client used, global singleton not touched) and single-server fallback path (global singleton used, `vault_client` not called) for `get`, `write`, `exist?`, `delete`, and `read` methods
|
|
13
|
+
- SPIFFE/SVID support implementing GitHub issue #8: `Spiffe::WorkloadApiClient` (Unix-domain gRPC for x509/JWT SVIDs with self-signed fallback), `Spiffe::SvidRotation` (background renewal at configurable window), `Spiffe::IdentityHelpers` mixin (sign/verify/extract/trust helpers); wired into `Crypt.start`/`shutdown` behind `spiffe.enabled: false` feature flag
|
|
14
|
+
- `spiffe` default settings block with `enabled`, `socket_path`, `trust_domain`, `workload_id`, `renewal_window`
|
|
15
|
+
- 82 specs covering SPIFFE ID parsing, SVID lifecycle, Workload API client (with mocked socket), self-signed fallback, protobuf field decoding, signing/verification, SAN extraction, and trust chain validation (closes #8)
|
|
16
|
+
|
|
17
|
+
## [1.4.24] - 2026-03-28
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- `LeaseManager#start`: no longer creates new Vault dynamic credentials when a valid cached lease already exists, preventing orphaned RabbitMQ users on repeated `start` calls (closes #6)
|
|
21
|
+
- `LeaseManager#start`: expired leases are now revoked before re-fetching, ensuring clean credential rotation
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- `LeaseManager#lease_valid?`: returns true when a named lease is cached and its `expires_at` is in the future
|
|
25
|
+
- `LeaseManager#revoke_expired_lease`: revokes and clears a stale cached lease entry before a re-fetch
|
|
26
|
+
- Specs for repeated `start` idempotency, expired-lease re-fetch, `lease_valid?` edge cases
|
|
27
|
+
|
|
3
28
|
## [1.4.23] - 2026-03-27
|
|
4
29
|
|
|
5
30
|
### Fixed
|
|
@@ -25,6 +25,13 @@ module Legion
|
|
|
25
25
|
path = opts['path'] || opts[:path]
|
|
26
26
|
next unless path
|
|
27
27
|
|
|
28
|
+
if lease_valid?(name)
|
|
29
|
+
log_debug("LeaseManager: reusing valid cached lease for '#{name}'")
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
revoke_expired_lease(name)
|
|
34
|
+
|
|
28
35
|
begin
|
|
29
36
|
response = logical.read(path)
|
|
30
37
|
unless response
|
|
@@ -174,6 +181,34 @@ module Legion
|
|
|
174
181
|
log_warn("LeaseManager: failed to renew lease '#{name}': #{e.message}")
|
|
175
182
|
end
|
|
176
183
|
|
|
184
|
+
def lease_valid?(name)
|
|
185
|
+
meta = @active_leases[name]
|
|
186
|
+
return false unless meta
|
|
187
|
+
|
|
188
|
+
expires_at = meta[:expires_at]
|
|
189
|
+
return false unless expires_at
|
|
190
|
+
|
|
191
|
+
expires_at > Time.now
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def revoke_expired_lease(name)
|
|
195
|
+
meta = @active_leases[name]
|
|
196
|
+
return unless meta
|
|
197
|
+
|
|
198
|
+
lease_id = meta[:lease_id]
|
|
199
|
+
return if lease_id.nil? || lease_id.empty?
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
sys.revoke(lease_id)
|
|
203
|
+
log_debug("LeaseManager: revoked expired lease '#{name}' (#{lease_id}) before re-fetch")
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
log_warn("LeaseManager: failed to revoke expired lease '#{name}' (#{lease_id}): #{e.message}")
|
|
206
|
+
ensure
|
|
207
|
+
@active_leases.delete(name)
|
|
208
|
+
@lease_cache.delete(name)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
177
212
|
def approaching_expiry?(lease)
|
|
178
213
|
expires_at = lease[:expires_at]
|
|
179
214
|
lease_duration = lease[:lease_duration]
|
|
@@ -13,6 +13,16 @@ module Legion
|
|
|
13
13
|
}
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
def self.spiffe
|
|
17
|
+
{
|
|
18
|
+
enabled: false,
|
|
19
|
+
socket_path: '/tmp/spire-agent/public/api.sock',
|
|
20
|
+
trust_domain: 'legion.internal',
|
|
21
|
+
workload_id: nil,
|
|
22
|
+
renewal_window: 0.5
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
16
26
|
def self.default
|
|
17
27
|
{
|
|
18
28
|
vault: vault,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'base64'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Crypt
|
|
8
|
+
module Spiffe
|
|
9
|
+
# Helpers for signing, verifying, and inspecting SPIFFE SVIDs.
|
|
10
|
+
#
|
|
11
|
+
# These methods work directly with X509Svid and JwtSvid structs
|
|
12
|
+
# returned by WorkloadApiClient. No external gem is required —
|
|
13
|
+
# all operations use the Ruby stdlib OpenSSL bindings.
|
|
14
|
+
module IdentityHelpers
|
|
15
|
+
# Sign arbitrary data with the private key from an X.509 SVID.
|
|
16
|
+
# Returns the signature as a Base64-encoded string.
|
|
17
|
+
#
|
|
18
|
+
# @param data [String] The bytes to sign (any encoding; treated as binary).
|
|
19
|
+
# @param svid [X509Svid] An X509Svid whose key_pem is populated.
|
|
20
|
+
# @return [String] Base64-encoded DER signature.
|
|
21
|
+
def sign_with_svid(data, svid:)
|
|
22
|
+
raise SvidError, 'Cannot sign: SVID is nil' if svid.nil?
|
|
23
|
+
raise SvidError, "Cannot sign: SVID '#{svid.spiffe_id}' has expired" if svid.expired?
|
|
24
|
+
raise SvidError, 'Cannot sign: SVID private key is missing' if svid.key_pem.nil?
|
|
25
|
+
|
|
26
|
+
key = OpenSSL::PKey.read(svid.key_pem)
|
|
27
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
28
|
+
signature = key.sign(digest, data.b)
|
|
29
|
+
Base64.strict_encode64(signature)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Verify a Base64-encoded signature produced by sign_with_svid.
|
|
33
|
+
#
|
|
34
|
+
# @param data [String] Original data that was signed.
|
|
35
|
+
# @param signature_b64 [String] Base64-encoded signature from sign_with_svid.
|
|
36
|
+
# @param svid [X509Svid] The SVID whose public certificate is used for verification.
|
|
37
|
+
# @return [Boolean] true if the signature is valid.
|
|
38
|
+
def verify_svid_signature(data, signature_b64:, svid:)
|
|
39
|
+
raise SvidError, 'Cannot verify: SVID is nil' if svid.nil?
|
|
40
|
+
raise SvidError, "Cannot verify: SVID '#{svid.spiffe_id}' has expired" if svid.expired?
|
|
41
|
+
raise SvidError, 'Cannot verify: SVID certificate is missing' if svid.cert_pem.nil?
|
|
42
|
+
|
|
43
|
+
cert = OpenSSL::X509::Certificate.new(svid.cert_pem)
|
|
44
|
+
digest = OpenSSL::Digest.new('SHA256')
|
|
45
|
+
signature = Base64.strict_decode64(signature_b64)
|
|
46
|
+
cert.public_key.verify(digest, signature, data.b)
|
|
47
|
+
rescue OpenSSL::PKey::PKeyError, OpenSSL::X509::CertificateError, ArgumentError => e
|
|
48
|
+
log_spiffe_warn("SVID signature verification error: #{e.message}")
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Extract the SPIFFE ID embedded in an X.509 certificate's SAN URI extension.
|
|
53
|
+
# Returns a SpiffeId struct or nil if none is found.
|
|
54
|
+
#
|
|
55
|
+
# @param cert_pem [String] PEM-encoded X.509 certificate.
|
|
56
|
+
# @return [SpiffeId, nil]
|
|
57
|
+
def extract_spiffe_id_from_cert(cert_pem)
|
|
58
|
+
cert = OpenSSL::X509::Certificate.new(cert_pem)
|
|
59
|
+
san = cert.extensions.find { |e| e.oid == 'subjectAltName' }
|
|
60
|
+
return nil unless san
|
|
61
|
+
|
|
62
|
+
san.value.split(',').each do |entry|
|
|
63
|
+
entry = entry.strip
|
|
64
|
+
next unless entry.start_with?('URI:spiffe://')
|
|
65
|
+
|
|
66
|
+
uri = entry.sub('URI:', '')
|
|
67
|
+
return Legion::Crypt::Spiffe.parse_id(uri)
|
|
68
|
+
rescue InvalidSpiffeIdError
|
|
69
|
+
next
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
rescue OpenSSL::X509::CertificateError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Validate that a certificate chain is trusted by the bundle embedded
|
|
78
|
+
# in the given SVID. Returns true if the leaf cert chains up to the
|
|
79
|
+
# bundle CA, false otherwise.
|
|
80
|
+
#
|
|
81
|
+
# @param cert_pem [String] PEM-encoded leaf certificate to validate.
|
|
82
|
+
# @param svid [X509Svid] SVID whose bundle_pem contains the trust anchor.
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def trusted_cert?(cert_pem, svid:)
|
|
85
|
+
raise SvidError, 'Cannot check trust: SVID is nil' if svid.nil?
|
|
86
|
+
return false if svid.bundle_pem.nil?
|
|
87
|
+
|
|
88
|
+
store = OpenSSL::X509::Store.new
|
|
89
|
+
store.add_cert(OpenSSL::X509::Certificate.new(svid.bundle_pem))
|
|
90
|
+
|
|
91
|
+
leaf = OpenSSL::X509::Certificate.new(cert_pem)
|
|
92
|
+
store.verify(leaf)
|
|
93
|
+
rescue OpenSSL::X509::CertificateError, OpenSSL::X509::StoreError
|
|
94
|
+
false
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Return a hash of identity information extracted from an SVID.
|
|
98
|
+
#
|
|
99
|
+
# @param svid [X509Svid, JwtSvid] Any SVID type.
|
|
100
|
+
# @return [Hash]
|
|
101
|
+
def svid_identity(svid)
|
|
102
|
+
return {} if svid.nil?
|
|
103
|
+
|
|
104
|
+
base = {
|
|
105
|
+
spiffe_id: svid.spiffe_id.to_s,
|
|
106
|
+
trust_domain: svid.spiffe_id.trust_domain,
|
|
107
|
+
workload_path: svid.spiffe_id.path,
|
|
108
|
+
expiry: svid.expiry,
|
|
109
|
+
expired: svid.expired?
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
case svid
|
|
113
|
+
when X509Svid
|
|
114
|
+
base.merge(type: :x509, ttl_seconds: svid.ttl.to_i)
|
|
115
|
+
when JwtSvid
|
|
116
|
+
base.merge(type: :jwt, audience: svid.audience)
|
|
117
|
+
else
|
|
118
|
+
base
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def log_spiffe_warn(message)
|
|
125
|
+
Legion::Logging.warn("[SPIFFE] #{message}") if defined?(Legion::Logging)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Crypt
|
|
5
|
+
module Spiffe
|
|
6
|
+
# Background thread that keeps the current X.509 SVID fresh.
|
|
7
|
+
#
|
|
8
|
+
# Mirrors the pattern used by CertRotation (mTLS) but targets the
|
|
9
|
+
# SPIFFE Workload API instead of Vault PKI. The check interval
|
|
10
|
+
# defaults to 60 seconds; renewal fires when the SVID is past 50%
|
|
11
|
+
# of its lifetime (configurable via security.spiffe.renewal_window).
|
|
12
|
+
class SvidRotation
|
|
13
|
+
DEFAULT_CHECK_INTERVAL = 60
|
|
14
|
+
|
|
15
|
+
attr_reader :check_interval, :current_svid
|
|
16
|
+
|
|
17
|
+
def initialize(check_interval: DEFAULT_CHECK_INTERVAL, client: nil)
|
|
18
|
+
@check_interval = check_interval
|
|
19
|
+
@client = client || WorkloadApiClient.new
|
|
20
|
+
@current_svid = nil
|
|
21
|
+
@issued_at = nil
|
|
22
|
+
@running = false
|
|
23
|
+
@thread = nil
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
return unless Legion::Crypt::Spiffe.enabled?
|
|
29
|
+
return if running?
|
|
30
|
+
|
|
31
|
+
@running = true
|
|
32
|
+
@thread = Thread.new { rotation_loop }
|
|
33
|
+
@thread.name = 'spiffe-svid-rotation'
|
|
34
|
+
log_info('[SPIFFE] SvidRotation started')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def stop
|
|
38
|
+
@running = false
|
|
39
|
+
begin
|
|
40
|
+
@thread&.wakeup
|
|
41
|
+
rescue ThreadError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
@thread&.join(3)
|
|
45
|
+
@thread = nil
|
|
46
|
+
log_debug('[SPIFFE] SvidRotation stopped')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def running?
|
|
50
|
+
(@running && @thread&.alive?) || false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def rotate!
|
|
54
|
+
svid = @client.fetch_x509_svid
|
|
55
|
+
@mutex.synchronize do
|
|
56
|
+
@current_svid = svid
|
|
57
|
+
@issued_at = Time.now
|
|
58
|
+
end
|
|
59
|
+
log_info("[SPIFFE] SVID rotated: id=#{svid.spiffe_id} expiry=#{svid.expiry}")
|
|
60
|
+
svid
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def needs_renewal?
|
|
64
|
+
svid = nil
|
|
65
|
+
issued_at = nil
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
svid = @current_svid
|
|
68
|
+
issued_at = @issued_at
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return true if svid.nil? || issued_at.nil?
|
|
72
|
+
return true if svid.expired?
|
|
73
|
+
|
|
74
|
+
total = svid.expiry - issued_at
|
|
75
|
+
return true if total <= 0
|
|
76
|
+
|
|
77
|
+
remaining = svid.expiry - Time.now
|
|
78
|
+
fraction = remaining / total
|
|
79
|
+
fraction < renewal_window
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def rotation_loop
|
|
85
|
+
begin
|
|
86
|
+
rotate!
|
|
87
|
+
rescue StandardError => e
|
|
88
|
+
log_warn("[SPIFFE] Initial SVID fetch failed: #{e.message}")
|
|
89
|
+
end
|
|
90
|
+
loop_check
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def loop_check
|
|
94
|
+
while @running
|
|
95
|
+
interruptible_sleep(@check_interval)
|
|
96
|
+
next unless @running && needs_renewal?
|
|
97
|
+
|
|
98
|
+
begin
|
|
99
|
+
rotate!
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
log_warn("[SPIFFE] SVID rotation failed: #{e.message}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
log_warn("[SPIFFE] SvidRotation loop error: #{e.message}")
|
|
106
|
+
retry if @running
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def interruptible_sleep(seconds)
|
|
110
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
|
|
111
|
+
loop do
|
|
112
|
+
remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
113
|
+
break if remaining <= 0 || !@running
|
|
114
|
+
|
|
115
|
+
sleep([remaining, 1.0].min)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def renewal_window
|
|
120
|
+
return SVID_RENEWAL_RATIO unless defined?(Legion::Settings)
|
|
121
|
+
|
|
122
|
+
security = Legion::Settings[:security]
|
|
123
|
+
return SVID_RENEWAL_RATIO if security.nil?
|
|
124
|
+
|
|
125
|
+
spiffe = security[:spiffe] || security['spiffe'] || {}
|
|
126
|
+
spiffe[:renewal_window] || spiffe['renewal_window'] || SVID_RENEWAL_RATIO
|
|
127
|
+
rescue StandardError
|
|
128
|
+
SVID_RENEWAL_RATIO
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def log_info(msg)
|
|
132
|
+
if defined?(Legion::Logging)
|
|
133
|
+
Legion::Logging.info(msg)
|
|
134
|
+
else
|
|
135
|
+
$stdout.puts(msg)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def log_debug(msg)
|
|
140
|
+
if defined?(Legion::Logging)
|
|
141
|
+
Legion::Logging.debug(msg)
|
|
142
|
+
else
|
|
143
|
+
$stdout.puts("[DEBUG] #{msg}")
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def log_warn(msg)
|
|
148
|
+
if defined?(Legion::Logging)
|
|
149
|
+
Legion::Logging.warn(msg)
|
|
150
|
+
else
|
|
151
|
+
warn("[WARN] #{msg}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Crypt
|
|
8
|
+
module Spiffe
|
|
9
|
+
# Minimal SPIFFE Workload API client.
|
|
10
|
+
#
|
|
11
|
+
# The SPIFFE Workload API is served over a Unix domain socket by a local
|
|
12
|
+
# SPIRE agent. The wire protocol is gRPC/HTTP2, but we avoid pulling in
|
|
13
|
+
# a full gRPC stack by implementing just enough of the HTTP/2 framing to
|
|
14
|
+
# send a single unary RPC call and parse a single response.
|
|
15
|
+
#
|
|
16
|
+
# For environments that cannot make a real SPIRE call (CI, lite mode,
|
|
17
|
+
# no socket present) the client returns a self-signed fallback SVID so
|
|
18
|
+
# that callers never have to special-case the nil case.
|
|
19
|
+
class WorkloadApiClient
|
|
20
|
+
# gRPC content-type and method path for the Workload API FetchX509SVID RPC.
|
|
21
|
+
GRPC_CONTENT_TYPE = 'application/grpc'
|
|
22
|
+
FETCH_X509_METHOD = '/spiffe.workload.SpiffeWorkloadAPI/FetchX509SVID'
|
|
23
|
+
FETCH_JWT_METHOD = '/spiffe.workload.SpiffeWorkloadAPI/FetchJWTSVID'
|
|
24
|
+
|
|
25
|
+
# Handshake + settings frames required to open an HTTP/2 connection.
|
|
26
|
+
HTTP2_PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"
|
|
27
|
+
HTTP2_SETTINGS_FRAME = [0, 4, 0, 0, 0, 0].pack('NnCCNN')
|
|
28
|
+
|
|
29
|
+
CONNECT_TIMEOUT = 5
|
|
30
|
+
READ_TIMEOUT = 10
|
|
31
|
+
|
|
32
|
+
def initialize(socket_path: nil, trust_domain: nil)
|
|
33
|
+
@socket_path = socket_path || Legion::Crypt::Spiffe.socket_path
|
|
34
|
+
@trust_domain = trust_domain || Legion::Crypt::Spiffe.trust_domain
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Fetch an X.509 SVID from the SPIRE Workload API.
|
|
38
|
+
# Returns a populated X509Svid struct.
|
|
39
|
+
# Falls back to a self-signed certificate when the Workload API is unavailable.
|
|
40
|
+
def fetch_x509_svid
|
|
41
|
+
raw = call_workload_api(FETCH_X509_METHOD, '')
|
|
42
|
+
parse_x509_svid_response(raw)
|
|
43
|
+
rescue WorkloadApiError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Errno::EPIPE => e
|
|
44
|
+
log_warn("[SPIFFE] Workload API unavailable (#{e.message}); using self-signed fallback")
|
|
45
|
+
self_signed_fallback
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fetch a JWT SVID from the SPIRE Workload API for the given audience.
|
|
49
|
+
def fetch_jwt_svid(audience:)
|
|
50
|
+
payload = encode_jwt_request(audience)
|
|
51
|
+
raw = call_workload_api(FETCH_JWT_METHOD, payload)
|
|
52
|
+
parse_jwt_svid_response(raw, audience)
|
|
53
|
+
rescue WorkloadApiError, IOError, Errno::ENOENT, Errno::ECONNREFUSED, Errno::EPIPE => e
|
|
54
|
+
log_warn("[SPIFFE] JWT SVID fetch failed (#{e.message})")
|
|
55
|
+
raise SvidError, "Failed to fetch JWT SVID for audience '#{audience}': #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns true when the SPIRE agent socket exists and is reachable.
|
|
59
|
+
def available?
|
|
60
|
+
return false unless ::File.exist?(@socket_path)
|
|
61
|
+
|
|
62
|
+
sock = UNIXSocket.new(@socket_path)
|
|
63
|
+
sock.close
|
|
64
|
+
true
|
|
65
|
+
rescue StandardError
|
|
66
|
+
false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Minimal HTTP/2 + gRPC unary call over a Unix domain socket.
|
|
72
|
+
# This is intentionally simple: one request frame, one response frame.
|
|
73
|
+
def call_workload_api(method_path, request_body)
|
|
74
|
+
sock = connect_socket
|
|
75
|
+
begin
|
|
76
|
+
send_grpc_request(sock, method_path, request_body)
|
|
77
|
+
read_grpc_response(sock)
|
|
78
|
+
ensure
|
|
79
|
+
sock.close rescue nil # rubocop:disable Style/RescueModifier
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def connect_socket
|
|
84
|
+
raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'" unless ::File.exist?(@socket_path)
|
|
85
|
+
|
|
86
|
+
sock = UNIXSocket.new(@socket_path)
|
|
87
|
+
# Write HTTP/2 connection preface and initial SETTINGS frame.
|
|
88
|
+
sock.write(HTTP2_PREFACE)
|
|
89
|
+
sock.write(HTTP2_SETTINGS_FRAME)
|
|
90
|
+
sock.flush
|
|
91
|
+
sock
|
|
92
|
+
rescue Errno::ENOENT
|
|
93
|
+
raise WorkloadApiError, "SPIRE agent socket not found at '#{@socket_path}'"
|
|
94
|
+
rescue Errno::ECONNREFUSED, Errno::EACCES => e
|
|
95
|
+
raise WorkloadApiError, "Cannot connect to SPIRE agent socket: #{e.message}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build and send a minimal gRPC/HTTP2 HEADERS + DATA frame.
|
|
99
|
+
# We encode only the fields the SPIRE agent needs to accept the request.
|
|
100
|
+
def send_grpc_request(sock, method_path, body)
|
|
101
|
+
headers = build_grpc_headers(method_path)
|
|
102
|
+
headers_frame = encode_http2_frame(type: 0x01, flags: 0x04, stream_id: 1, payload: headers)
|
|
103
|
+
data_frame = encode_grpc_data_frame(body)
|
|
104
|
+
sock.write(headers_frame + data_frame)
|
|
105
|
+
sock.flush
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def build_grpc_headers(method_path)
|
|
109
|
+
# Minimal set of pseudo-headers and gRPC headers encoded as HPACK literals.
|
|
110
|
+
# We use the no-indexing literal representation for simplicity.
|
|
111
|
+
encode_header(':method', 'POST') +
|
|
112
|
+
encode_header(':path', method_path) +
|
|
113
|
+
encode_header(':scheme', 'http') +
|
|
114
|
+
encode_header(':authority', 'localhost') +
|
|
115
|
+
encode_header('content-type', GRPC_CONTENT_TYPE) +
|
|
116
|
+
encode_header('te', 'trailers')
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Encode a single HPACK literal header field (no indexing).
|
|
120
|
+
def encode_header(name, value)
|
|
121
|
+
name_bytes = name.b
|
|
122
|
+
value_bytes = value.b
|
|
123
|
+
[0x00].pack('C') +
|
|
124
|
+
encode_hpack_string(name_bytes) +
|
|
125
|
+
encode_hpack_string(value_bytes)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def encode_hpack_string(bytes)
|
|
129
|
+
# Length prefix (non-Huffman).
|
|
130
|
+
len = bytes.bytesize
|
|
131
|
+
if len < 128
|
|
132
|
+
[len].pack('C') + bytes
|
|
133
|
+
else
|
|
134
|
+
# Multi-byte length encoding (RFC 7541 §5.1).
|
|
135
|
+
parts = [0x80 | (len & 0x7F)].pack('C')
|
|
136
|
+
len >>= 7
|
|
137
|
+
parts += [(len.positive? ? 0x80 : 0x00) | (len & 0x7F)].pack('C')
|
|
138
|
+
parts + bytes
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Encode a gRPC message as a DATA frame (5-byte gRPC header + body).
|
|
143
|
+
def encode_grpc_data_frame(body)
|
|
144
|
+
grpc_header = [0, body.bytesize].pack('CN') # compressed-flag + length
|
|
145
|
+
payload = grpc_header + body.b
|
|
146
|
+
encode_http2_frame(type: 0x00, flags: 0x01, stream_id: 1, payload: payload)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Build an HTTP/2 frame (RFC 7540 §4.1).
|
|
150
|
+
def encode_http2_frame(type:, flags:, stream_id:, payload:)
|
|
151
|
+
length = payload.bytesize
|
|
152
|
+
# 3-byte length + 1-byte type + 1-byte flags + 4-byte stream_id (MSB=0)
|
|
153
|
+
[length >> 16, (length >> 8) & 0xFF, length & 0xFF, type, flags].pack('CCCCC') +
|
|
154
|
+
[stream_id & 0x7FFFFFFF].pack('N') +
|
|
155
|
+
payload.b
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def read_grpc_response(sock)
|
|
159
|
+
# Read until we see a DATA frame containing a gRPC message or timeout.
|
|
160
|
+
deadline = Time.now + READ_TIMEOUT
|
|
161
|
+
buffer = ''.b
|
|
162
|
+
|
|
163
|
+
loop do
|
|
164
|
+
raise WorkloadApiError, 'Workload API read timeout' if Time.now > deadline
|
|
165
|
+
|
|
166
|
+
ready = sock.wait_readable(1.0)
|
|
167
|
+
next unless ready
|
|
168
|
+
|
|
169
|
+
chunk = sock.read_nonblock(4096, exception: false)
|
|
170
|
+
break if chunk == :wait_readable || chunk.nil?
|
|
171
|
+
|
|
172
|
+
buffer += chunk.b
|
|
173
|
+
result = extract_grpc_body(buffer)
|
|
174
|
+
return result if result
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
raise WorkloadApiError, 'No valid gRPC response received from Workload API'
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Scan the raw HTTP/2 buffer for a DATA frame (type=0x00) that contains
|
|
181
|
+
# a non-empty gRPC message and return the message body bytes.
|
|
182
|
+
def extract_grpc_body(buffer)
|
|
183
|
+
pos = 0
|
|
184
|
+
while pos + 9 <= buffer.bytesize
|
|
185
|
+
frame_length = (buffer.getbyte(pos) << 16) | (buffer.getbyte(pos + 1) << 8) | buffer.getbyte(pos + 2)
|
|
186
|
+
frame_type = buffer.getbyte(pos + 3)
|
|
187
|
+
pos += 9 # skip frame header
|
|
188
|
+
|
|
189
|
+
if pos + frame_length > buffer.bytesize
|
|
190
|
+
# Incomplete frame — need more data.
|
|
191
|
+
return nil
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
payload = buffer.byteslice(pos, frame_length)
|
|
195
|
+
pos += frame_length
|
|
196
|
+
|
|
197
|
+
next unless frame_type.zero? && payload && payload.bytesize >= 5
|
|
198
|
+
|
|
199
|
+
# gRPC message: 1-byte compressed flag + 4-byte length + body
|
|
200
|
+
compressed = payload.getbyte(0)
|
|
201
|
+
msg_length = (payload.getbyte(1) << 24) | (payload.getbyte(2) << 16) |
|
|
202
|
+
(payload.getbyte(3) << 8) | payload.getbyte(4)
|
|
203
|
+
next if msg_length.zero?
|
|
204
|
+
|
|
205
|
+
msg_body = payload.byteslice(5, msg_length)
|
|
206
|
+
next if msg_body.nil? || msg_body.bytesize < msg_length
|
|
207
|
+
|
|
208
|
+
# Compressed gRPC responses are not expected from SPIRE; skip them.
|
|
209
|
+
next unless compressed.zero?
|
|
210
|
+
|
|
211
|
+
return msg_body
|
|
212
|
+
end
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Minimal protobuf encoding for JWTSVIDParams { audience: [string], id: SpiffeID }.
|
|
217
|
+
# We only need field 1 (audience, repeated string).
|
|
218
|
+
def encode_jwt_request(audience)
|
|
219
|
+
audience_bytes = audience.b
|
|
220
|
+
# Field 1, wire type 2 (length-delimited) = tag 0x0A
|
|
221
|
+
"\n#{[audience_bytes.bytesize].pack('C')}#{audience_bytes}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Parse the raw protobuf bytes from FetchX509SVIDResponse into an X509Svid.
|
|
225
|
+
# Field layout (spiffe.workload.X509SVIDResponse.svids[0]):
|
|
226
|
+
# svids: repeated X509SVID (field 1)
|
|
227
|
+
# spiffe_id: string (field 1)
|
|
228
|
+
# x509_svid: bytes (field 2) — DER-encoded cert chain
|
|
229
|
+
# x509_svid_key: bytes (field 3) — DER-encoded private key (PKCS8)
|
|
230
|
+
# bundle: bytes (field 4) — DER-encoded CA bundle
|
|
231
|
+
def parse_x509_svid_response(raw)
|
|
232
|
+
svid_bytes = extract_proto_field(raw, field_number: 1)
|
|
233
|
+
raise SvidError, 'Empty X.509 SVID response from Workload API' if svid_bytes.nil? || svid_bytes.empty?
|
|
234
|
+
|
|
235
|
+
spiffe_id_str = extract_proto_string(svid_bytes, field_number: 1)
|
|
236
|
+
cert_der = extract_proto_bytes(svid_bytes, field_number: 2)
|
|
237
|
+
key_der = extract_proto_bytes(svid_bytes, field_number: 3)
|
|
238
|
+
bundle_der = extract_proto_bytes(svid_bytes, field_number: 4)
|
|
239
|
+
|
|
240
|
+
raise SvidError, 'X.509 SVID missing certificate data' if cert_der.nil? || cert_der.empty?
|
|
241
|
+
raise SvidError, 'X.509 SVID missing private key data' if key_der.nil? || key_der.empty?
|
|
242
|
+
|
|
243
|
+
cert = OpenSSL::X509::Certificate.new(cert_der)
|
|
244
|
+
key = OpenSSL::PKey.read(key_der)
|
|
245
|
+
bundle_pem = bundle_der ? OpenSSL::X509::Certificate.new(bundle_der).to_pem : nil
|
|
246
|
+
spiffe_id = Legion::Crypt::Spiffe.parse_id(spiffe_id_str)
|
|
247
|
+
|
|
248
|
+
X509Svid.new(
|
|
249
|
+
spiffe_id: spiffe_id,
|
|
250
|
+
cert_pem: cert.to_pem,
|
|
251
|
+
key_pem: key.private_to_pem,
|
|
252
|
+
bundle_pem: bundle_pem,
|
|
253
|
+
expiry: cert.not_after
|
|
254
|
+
)
|
|
255
|
+
rescue OpenSSL::X509::CertificateError, OpenSSL::PKey::PKeyError => e
|
|
256
|
+
raise SvidError, "Failed to parse X.509 SVID: #{e.message}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Parse the raw protobuf bytes from FetchJWTSVIDResponse into a JwtSvid.
|
|
260
|
+
# Field layout:
|
|
261
|
+
# svids: repeated JWTSVID (field 1)
|
|
262
|
+
# spiffe_id: string (field 1)
|
|
263
|
+
# svid: string (field 2) — the JWT token
|
|
264
|
+
def parse_jwt_svid_response(raw, audience)
|
|
265
|
+
svid_bytes = extract_proto_field(raw, field_number: 1)
|
|
266
|
+
raise SvidError, 'Empty JWT SVID response from Workload API' if svid_bytes.nil? || svid_bytes.empty?
|
|
267
|
+
|
|
268
|
+
spiffe_id_str = extract_proto_string(svid_bytes, field_number: 1)
|
|
269
|
+
token = extract_proto_string(svid_bytes, field_number: 2)
|
|
270
|
+
|
|
271
|
+
raise SvidError, 'JWT SVID missing token' if token.nil? || token.empty?
|
|
272
|
+
|
|
273
|
+
expiry = extract_jwt_expiry(token)
|
|
274
|
+
spiffe_id = Legion::Crypt::Spiffe.parse_id(spiffe_id_str)
|
|
275
|
+
|
|
276
|
+
JwtSvid.new(
|
|
277
|
+
spiffe_id: spiffe_id,
|
|
278
|
+
token: token,
|
|
279
|
+
audience: audience,
|
|
280
|
+
expiry: expiry
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Build a self-signed X.509 SVID for use when SPIRE is not available.
|
|
285
|
+
# The SPIFFE ID is placed in the SAN URI extension per the SPIFFE spec.
|
|
286
|
+
# The Subject CN is a plain workload name (no URI) so OpenSSL parses cleanly.
|
|
287
|
+
def self_signed_fallback
|
|
288
|
+
key = OpenSSL::PKey::EC.generate('prime256v1')
|
|
289
|
+
cert = OpenSSL::X509::Certificate.new
|
|
290
|
+
cert.version = 2
|
|
291
|
+
cert.serial = OpenSSL::BN.rand(128)
|
|
292
|
+
cert.not_before = Time.now
|
|
293
|
+
cert.not_after = Time.now + 3600
|
|
294
|
+
|
|
295
|
+
spiffe_id_str = "#{SPIFFE_SCHEME}://#{@trust_domain}/workload/legion"
|
|
296
|
+
subject = OpenSSL::X509::Name.parse('/CN=legion-fallback-svid')
|
|
297
|
+
cert.subject = subject
|
|
298
|
+
cert.issuer = subject
|
|
299
|
+
cert.public_key = key
|
|
300
|
+
|
|
301
|
+
ext_factory = OpenSSL::X509::ExtensionFactory.new(cert, cert)
|
|
302
|
+
cert.add_extension(ext_factory.create_extension('subjectAltName', "URI:#{spiffe_id_str}", false))
|
|
303
|
+
cert.add_extension(ext_factory.create_extension('basicConstraints', 'CA:FALSE', true))
|
|
304
|
+
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
305
|
+
|
|
306
|
+
X509Svid.new(
|
|
307
|
+
spiffe_id: Legion::Crypt::Spiffe.parse_id(spiffe_id_str),
|
|
308
|
+
cert_pem: cert.to_pem,
|
|
309
|
+
key_pem: key.private_to_pem,
|
|
310
|
+
bundle_pem: nil,
|
|
311
|
+
expiry: cert.not_after
|
|
312
|
+
)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# --- Minimal protobuf decoder ---
|
|
316
|
+
# Only handles wire types 0 (varint) and 2 (length-delimited).
|
|
317
|
+
|
|
318
|
+
def extract_proto_field(bytes, field_number:)
|
|
319
|
+
pos = 0
|
|
320
|
+
while pos < bytes.bytesize
|
|
321
|
+
tag, consumed = decode_varint(bytes, pos)
|
|
322
|
+
pos += consumed
|
|
323
|
+
wire_type = tag & 0x07
|
|
324
|
+
field = tag >> 3
|
|
325
|
+
|
|
326
|
+
case wire_type
|
|
327
|
+
when 0 # varint — skip
|
|
328
|
+
_, consumed = decode_varint(bytes, pos)
|
|
329
|
+
pos += consumed
|
|
330
|
+
when 2 # length-delimited
|
|
331
|
+
len, consumed = decode_varint(bytes, pos)
|
|
332
|
+
pos += consumed
|
|
333
|
+
data = bytes.byteslice(pos, len)
|
|
334
|
+
pos += len
|
|
335
|
+
return data if field == field_number
|
|
336
|
+
else
|
|
337
|
+
break # Unknown wire type — stop parsing
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def extract_proto_string(bytes, field_number:)
|
|
344
|
+
raw = extract_proto_field(bytes, field_number: field_number)
|
|
345
|
+
raw&.force_encoding('UTF-8')
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
alias extract_proto_bytes extract_proto_field
|
|
349
|
+
|
|
350
|
+
# Decode a protobuf varint starting at +start+ in +bytes+.
|
|
351
|
+
# Returns [decoded_value, bytes_consumed].
|
|
352
|
+
def decode_varint(bytes, start)
|
|
353
|
+
result = 0
|
|
354
|
+
shift = 0
|
|
355
|
+
current = start
|
|
356
|
+
loop do
|
|
357
|
+
byte = bytes.getbyte(current)
|
|
358
|
+
return [result, 0] if byte.nil?
|
|
359
|
+
|
|
360
|
+
current += 1
|
|
361
|
+
result |= (byte & 0x7F) << shift
|
|
362
|
+
shift += 7
|
|
363
|
+
break unless (byte & 0x80).nonzero?
|
|
364
|
+
end
|
|
365
|
+
[result, current - start]
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Extract the `exp` claim from the JWT payload without verifying the signature.
|
|
369
|
+
def extract_jwt_expiry(token)
|
|
370
|
+
parts = token.split('.')
|
|
371
|
+
return Time.now + 3600 unless parts.length >= 2
|
|
372
|
+
|
|
373
|
+
payload_json = Base64.urlsafe_decode64("#{parts[1]}==")
|
|
374
|
+
claims = begin
|
|
375
|
+
Legion::JSON.parse(payload_json)
|
|
376
|
+
rescue StandardError
|
|
377
|
+
{}
|
|
378
|
+
end
|
|
379
|
+
exp = claims['exp'] || claims[:exp]
|
|
380
|
+
exp ? Time.at(exp.to_i) : Time.now + 3600
|
|
381
|
+
rescue StandardError
|
|
382
|
+
Time.now + 3600
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def log_warn(message)
|
|
386
|
+
Legion::Logging.warn(message) if defined?(Legion::Logging)
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Crypt
|
|
8
|
+
module Spiffe
|
|
9
|
+
SPIFFE_SCHEME = 'spiffe'
|
|
10
|
+
DEFAULT_SOCKET_PATH = '/tmp/spire-agent/public/api.sock'
|
|
11
|
+
DEFAULT_TRUST_DOMAIN = 'legion.internal'
|
|
12
|
+
SVID_RENEWAL_RATIO = 0.5
|
|
13
|
+
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
class InvalidSpiffeIdError < Error; end
|
|
16
|
+
class WorkloadApiError < Error; end
|
|
17
|
+
class SvidError < Error; end
|
|
18
|
+
|
|
19
|
+
# Parsed representation of a SPIFFE ID.
|
|
20
|
+
# A SPIFFE ID has the form: spiffe://<trust-domain>/<workload-path>
|
|
21
|
+
SpiffeId = Struct.new(:trust_domain, :path) do
|
|
22
|
+
def to_s
|
|
23
|
+
"#{SPIFFE_SCHEME}://#{trust_domain}#{path}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def ==(other)
|
|
27
|
+
other.is_a?(SpiffeId) && trust_domain == other.trust_domain && path == other.path
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Parsed X.509 SVID (SPIFFE Verifiable Identity Document).
|
|
32
|
+
X509Svid = Struct.new(:spiffe_id, :cert_pem, :key_pem, :bundle_pem, :expiry) do
|
|
33
|
+
def expired?
|
|
34
|
+
Time.now >= expiry
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def valid?
|
|
38
|
+
!cert_pem.nil? && !key_pem.nil? && !expired?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Seconds remaining until expiry (negative if already expired).
|
|
42
|
+
def ttl
|
|
43
|
+
expiry - Time.now
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Parsed JWT SVID.
|
|
48
|
+
JwtSvid = Struct.new(:spiffe_id, :token, :audience, :expiry) do
|
|
49
|
+
def expired?
|
|
50
|
+
Time.now >= expiry
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def valid?
|
|
54
|
+
!token.nil? && !expired?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# Parse a SPIFFE ID string into a SpiffeId struct.
|
|
60
|
+
# Raises InvalidSpiffeIdError on malformed input.
|
|
61
|
+
def parse_id(spiffe_id_string)
|
|
62
|
+
raise InvalidSpiffeIdError, 'SPIFFE ID must be a non-empty string' if spiffe_id_string.nil? || spiffe_id_string.empty?
|
|
63
|
+
|
|
64
|
+
uri = URI.parse(spiffe_id_string)
|
|
65
|
+
validate_uri!(uri, spiffe_id_string)
|
|
66
|
+
|
|
67
|
+
SpiffeId.new(
|
|
68
|
+
trust_domain: uri.host,
|
|
69
|
+
path: uri.path.empty? ? '/' : uri.path
|
|
70
|
+
)
|
|
71
|
+
rescue URI::InvalidURIError => e
|
|
72
|
+
raise InvalidSpiffeIdError, "Invalid SPIFFE ID '#{spiffe_id_string}': #{e.message}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def valid_id?(spiffe_id_string)
|
|
76
|
+
parse_id(spiffe_id_string)
|
|
77
|
+
true
|
|
78
|
+
rescue InvalidSpiffeIdError
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def enabled?
|
|
83
|
+
security = safe_security_settings
|
|
84
|
+
return false if security.nil?
|
|
85
|
+
|
|
86
|
+
spiffe = security[:spiffe] || security['spiffe']
|
|
87
|
+
return false if spiffe.nil?
|
|
88
|
+
|
|
89
|
+
spiffe[:enabled] || spiffe['enabled'] || false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def socket_path
|
|
93
|
+
security = safe_security_settings
|
|
94
|
+
return DEFAULT_SOCKET_PATH if security.nil?
|
|
95
|
+
|
|
96
|
+
spiffe = security[:spiffe] || security['spiffe'] || {}
|
|
97
|
+
spiffe[:socket_path] || spiffe['socket_path'] || DEFAULT_SOCKET_PATH
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def trust_domain
|
|
101
|
+
security = safe_security_settings
|
|
102
|
+
return DEFAULT_TRUST_DOMAIN if security.nil?
|
|
103
|
+
|
|
104
|
+
spiffe = security[:spiffe] || security['spiffe'] || {}
|
|
105
|
+
spiffe[:trust_domain] || spiffe['trust_domain'] || DEFAULT_TRUST_DOMAIN
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def workload_id
|
|
109
|
+
security = safe_security_settings
|
|
110
|
+
return nil if security.nil?
|
|
111
|
+
|
|
112
|
+
spiffe = security[:spiffe] || security['spiffe'] || {}
|
|
113
|
+
spiffe[:workload_id] || spiffe['workload_id']
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def validate_uri!(uri, raw)
|
|
119
|
+
unless uri.scheme == SPIFFE_SCHEME
|
|
120
|
+
raise InvalidSpiffeIdError,
|
|
121
|
+
"SPIFFE ID must use 'spiffe://' scheme, got '#{uri.scheme}://'"
|
|
122
|
+
end
|
|
123
|
+
raise InvalidSpiffeIdError, "SPIFFE ID missing trust domain in '#{raw}'" if uri.host.nil? || uri.host.empty?
|
|
124
|
+
return unless uri.userinfo || uri.port || (uri.query && !uri.query.empty?) || (uri.fragment && !uri.fragment.empty?)
|
|
125
|
+
|
|
126
|
+
raise InvalidSpiffeIdError, "SPIFFE ID must not contain userinfo, port, query, or fragment in '#{raw}'"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def safe_security_settings
|
|
130
|
+
return nil unless defined?(Legion::Settings)
|
|
131
|
+
|
|
132
|
+
Legion::Settings[:security]
|
|
133
|
+
rescue StandardError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
data/lib/legion/crypt/version.rb
CHANGED
data/lib/legion/crypt.rb
CHANGED
|
@@ -14,6 +14,10 @@ require 'legion/crypt/token_renewer'
|
|
|
14
14
|
require 'legion/crypt/helper'
|
|
15
15
|
require 'legion/crypt/mtls'
|
|
16
16
|
require 'legion/crypt/cert_rotation'
|
|
17
|
+
require 'legion/crypt/spiffe'
|
|
18
|
+
require 'legion/crypt/spiffe/workload_api_client'
|
|
19
|
+
require 'legion/crypt/spiffe/svid_rotation'
|
|
20
|
+
require 'legion/crypt/spiffe/identity_helpers'
|
|
17
21
|
|
|
18
22
|
module Legion
|
|
19
23
|
module Crypt
|
|
@@ -38,6 +42,20 @@ module Legion
|
|
|
38
42
|
KerberosAuth.kerberos_principal
|
|
39
43
|
end
|
|
40
44
|
|
|
45
|
+
def spiffe_svid
|
|
46
|
+
@svid_rotation&.current_svid
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def fetch_svid
|
|
50
|
+
@workload_client ||= Spiffe::WorkloadApiClient.new
|
|
51
|
+
@workload_client.fetch_x509_svid
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch_jwt_svid(audience:)
|
|
55
|
+
@workload_client ||= Spiffe::WorkloadApiClient.new
|
|
56
|
+
@workload_client.fetch_jwt_svid(audience: audience)
|
|
57
|
+
end
|
|
58
|
+
|
|
41
59
|
def start
|
|
42
60
|
Legion::Logging.debug 'Legion::Crypt is running start'
|
|
43
61
|
::File.write('./legionio.key', private_key) if settings[:save_private_key]
|
|
@@ -50,6 +68,7 @@ module Legion
|
|
|
50
68
|
connect_vault unless settings[:vault][:token].nil?
|
|
51
69
|
end
|
|
52
70
|
start_lease_manager
|
|
71
|
+
start_svid_rotation
|
|
53
72
|
end
|
|
54
73
|
|
|
55
74
|
def settings
|
|
@@ -96,6 +115,7 @@ module Legion
|
|
|
96
115
|
stop_token_renewers
|
|
97
116
|
shutdown_renewer
|
|
98
117
|
close_sessions
|
|
118
|
+
stop_svid_rotation
|
|
99
119
|
end
|
|
100
120
|
|
|
101
121
|
private
|
|
@@ -154,6 +174,22 @@ module Legion
|
|
|
154
174
|
@token_renewers.each(&:stop)
|
|
155
175
|
@token_renewers.clear
|
|
156
176
|
end
|
|
177
|
+
|
|
178
|
+
def start_svid_rotation
|
|
179
|
+
return unless Spiffe.enabled?
|
|
180
|
+
|
|
181
|
+
@svid_rotation = Spiffe::SvidRotation.new
|
|
182
|
+
@svid_rotation.start
|
|
183
|
+
rescue StandardError => e
|
|
184
|
+
Legion::Logging.warn "SPIFFE SvidRotation startup failed: #{e.message}" if defined?(Legion::Logging)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def stop_svid_rotation
|
|
188
|
+
return unless @svid_rotation
|
|
189
|
+
|
|
190
|
+
@svid_rotation.stop
|
|
191
|
+
@svid_rotation = nil
|
|
192
|
+
end
|
|
157
193
|
end
|
|
158
194
|
end
|
|
159
195
|
end
|
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.4.
|
|
4
|
+
version: 1.4.25
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -66,6 +66,7 @@ files:
|
|
|
66
66
|
- ".github/workflows/ci.yml"
|
|
67
67
|
- ".gitignore"
|
|
68
68
|
- ".rubocop.yml"
|
|
69
|
+
- AGENTS.md
|
|
69
70
|
- CHANGELOG.md
|
|
70
71
|
- CLAUDE.md
|
|
71
72
|
- CODEOWNERS
|
|
@@ -90,6 +91,10 @@ files:
|
|
|
90
91
|
- lib/legion/crypt/mtls.rb
|
|
91
92
|
- lib/legion/crypt/partition_keys.rb
|
|
92
93
|
- lib/legion/crypt/settings.rb
|
|
94
|
+
- lib/legion/crypt/spiffe.rb
|
|
95
|
+
- lib/legion/crypt/spiffe/identity_helpers.rb
|
|
96
|
+
- lib/legion/crypt/spiffe/svid_rotation.rb
|
|
97
|
+
- lib/legion/crypt/spiffe/workload_api_client.rb
|
|
93
98
|
- lib/legion/crypt/tls.rb
|
|
94
99
|
- lib/legion/crypt/token_renewer.rb
|
|
95
100
|
- lib/legion/crypt/vault.rb
|