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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bc1b26f800d0d5512ee35965b6700750bcfcacc5265042f749c3e56a973140c6
4
- data.tar.gz: cb154b6f1c44688b9a7d4d41e4c125279c9c945b8b162da448c1b2f7b0bacb67
3
+ metadata.gz: 37fe457155877f451f702c4de046bcf9bd5cabd8a86d9bbf51cd3c4484ab10ce
4
+ data.tar.gz: 642f9ddda00b7566be3001a847687bded7fc196a0e2330936f909b4753cb9eb3
5
5
  SHA512:
6
- metadata.gz: 4bf5a0be9d6c724dc0011508e7ce3ab23a8a9af46c7800fdd8a45711fa75e3569635bec16467d942306124c17c3a14c45bd161082b0e1c62a80c6172e9f050d9
7
- data.tar.gz: cd5269888634c81cda229646c13db9a61a67c687430bda6b5202047a73f84fb480b55e1e39534fcadec348b34ee62f0c913723338b20c3ffba6e31943fa38922
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Crypt
5
- VERSION = '1.4.23'
5
+ VERSION = '1.4.25'
6
6
  end
7
7
  end
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.23
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