spiffe-workload 0.2.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.
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grpc'
4
+ require 'thread'
5
+ require_relative 'x509_svid'
6
+ require_relative 'jwt_svid'
7
+ require_relative '../workload_pb'
8
+ require_relative '../workload_services_pb'
9
+
10
+ module Spiffe
11
+ module Workload
12
+ # Client for SPIFFE Workload API
13
+ # Connects to SPIRE Agent via Unix domain socket and fetches SVIDs
14
+ class Client
15
+ DEFAULT_SOCKET_PATH = '/run/spire/sockets/agent.sock'
16
+ DEFAULT_TIMEOUT = 5 # seconds
17
+
18
+ attr_reader :socket_path
19
+
20
+ # @param socket_path [String] Path to SPIRE Agent Unix socket
21
+ # @param timeout [Integer] Connection timeout in seconds
22
+ def initialize(socket_path: nil, timeout: DEFAULT_TIMEOUT)
23
+ @socket_path = socket_path || ENV['SPIFFE_ENDPOINT_SOCKET']&.sub('unix://', '') || DEFAULT_SOCKET_PATH
24
+ @timeout = timeout
25
+ @mutex = Mutex.new
26
+ @x509_svid_cache = nil
27
+ @x509_bundles_cache = nil
28
+ @jwt_bundles_cache = nil
29
+ @rotation_thread = nil
30
+ @shutdown = false
31
+
32
+ validate_socket!
33
+ end
34
+
35
+ # Fetch X.509 SVID (blocking call, returns first response)
36
+ # @return [X509SVIDWrapper] The default X.509 SVID
37
+ # @raise [Spiffe::Error] If fetch fails
38
+ def x509_svid
39
+ ensure_x509_svid_stream_started
40
+
41
+ @mutex.synchronize do
42
+ raise Spiffe::Error, 'No X.509 SVID available' unless @x509_svid_cache
43
+ @x509_svid_cache
44
+ end
45
+ end
46
+
47
+ # Get the current X.509 SVID without blocking
48
+ # @return [X509SVIDWrapper, nil]
49
+ def current_x509_svid
50
+ @mutex.synchronize { @x509_svid_cache }
51
+ end
52
+
53
+ # Fetch all X.509 SVIDs
54
+ # @return [Array<X509SVIDWrapper>]
55
+ def x509_svids
56
+ ensure_x509_svid_stream_started
57
+
58
+ @mutex.synchronize do
59
+ @x509_svids_cache || []
60
+ end
61
+ end
62
+
63
+ # Fetch JWT SVID for the given audience
64
+ # @param audience [String, Array<String>] Target audience(s)
65
+ # @param spiffe_id [String, nil] Optional specific SPIFFE ID to request
66
+ # @return [JWTSVIDWrapper] The JWT SVID
67
+ # @raise [Spiffe::Error] If fetch fails
68
+ def jwt_svid(audience:, spiffe_id: nil)
69
+ audiences = audience.is_a?(Array) ? audience : [audience]
70
+
71
+ request = Spiffe::Workload::JWTSVIDRequest.new(
72
+ audience: audiences,
73
+ spiffe_id: spiffe_id || ''
74
+ )
75
+
76
+ metadata = { 'workload.spiffe.io' => '1' }
77
+ response = stub.fetch_jwtsvid(request, metadata: metadata)
78
+
79
+ raise Spiffe::Error, 'No JWT SVID returned' if response.svids.empty?
80
+
81
+ JWTSVIDWrapper.from_proto(response.svids.first)
82
+ rescue GRPC::BadStatus => e
83
+ raise Spiffe::Error, "Failed to fetch JWT SVID: #{e.message}"
84
+ end
85
+
86
+ # Fetch X.509 trust bundles
87
+ # @return [Hash<String, OpenSSL::X509::Store>] Map of trust domain to bundle
88
+ def x509_bundles
89
+ ensure_x509_bundles_stream_started
90
+
91
+ @mutex.synchronize do
92
+ @x509_bundles_cache || {}
93
+ end
94
+ end
95
+
96
+ # Fetch JWT bundles
97
+ # @return [Hash<String, String>] Map of trust domain to JWKS
98
+ def jwt_bundles
99
+ ensure_jwt_bundles_stream_started
100
+
101
+ @mutex.synchronize do
102
+ @jwt_bundles_cache || {}
103
+ end
104
+ end
105
+
106
+ # Create an OpenSSL context configured with the current SVID
107
+ # @return [OpenSSL::SSL::SSLContext]
108
+ def tls_context
109
+ svid = x509_svid
110
+
111
+ context = OpenSSL::SSL::SSLContext.new
112
+ context.cert = svid.leaf_certificate
113
+ context.key = svid.private_key
114
+ context.extra_chain_cert = svid.cert_chain[1..-1] if svid.cert_chain.length > 1
115
+ context.cert_store = svid.trust_bundle
116
+ context.verify_mode = OpenSSL::SSL::VERIFY_PEER
117
+ context
118
+ end
119
+
120
+ # Register a callback to be called when X.509 SVID is updated
121
+ # @yield [X509SVID] The new SVID
122
+ def on_x509_svid_update(&block)
123
+ @mutex.synchronize do
124
+ @x509_update_callbacks ||= []
125
+ @x509_update_callbacks << block
126
+ end
127
+ end
128
+
129
+ # Shutdown the client and stop all background threads
130
+ def shutdown
131
+ @shutdown = true
132
+ @rotation_thread&.kill
133
+ @x509_stream_thread&.kill
134
+ @x509_bundles_stream_thread&.kill
135
+ @jwt_bundles_stream_thread&.kill
136
+ end
137
+
138
+ private
139
+
140
+ def validate_socket!
141
+ unless File.exist?(@socket_path)
142
+ raise Spiffe::SocketError, "Socket does not exist: #{@socket_path}"
143
+ end
144
+
145
+ unless File.socket?(@socket_path)
146
+ raise Spiffe::SocketError, "Path is not a socket: #{@socket_path}"
147
+ end
148
+
149
+ # Check socket permissions (should be accessible)
150
+ unless File.readable?(@socket_path) && File.writable?(@socket_path)
151
+ raise Spiffe::SocketError, "Socket is not accessible: #{@socket_path}"
152
+ end
153
+ end
154
+
155
+ # Interceptor to add SPIRE security header to all requests
156
+ class SpireHeaderInterceptor < GRPC::ClientInterceptor
157
+ def request_response(request:, call:, method:, metadata:)
158
+ metadata['workload.spiffe.io'] = '1'
159
+ yield
160
+ end
161
+
162
+ def client_streamer(requests:, call:, method:, metadata:)
163
+ metadata['workload.spiffe.io'] = '1'
164
+ yield
165
+ end
166
+
167
+ def server_streamer(request:, call:, method:, metadata:)
168
+ metadata['workload.spiffe.io'] = '1'
169
+ yield
170
+ end
171
+
172
+ def bidi_streamer(requests:, call:, method:, metadata:)
173
+ metadata['workload.spiffe.io'] = '1'
174
+ yield
175
+ end
176
+ end
177
+
178
+ def stub
179
+ @stub ||= begin
180
+ # Create gRPC channel to Unix socket
181
+ # Format: unix:///path/to/socket or unix:/path/to/socket
182
+ socket_uri = "unix://#{@socket_path}"
183
+
184
+ channel = GRPC::Core::Channel.new(
185
+ socket_uri,
186
+ {},
187
+ :this_channel_is_insecure
188
+ )
189
+
190
+ Spiffe::Workload::SpiffeWorkloadAPI::Stub.new(
191
+ nil, # host (unused with channel)
192
+ nil, # creds (unused with channel)
193
+ channel_override: channel,
194
+ timeout: @timeout
195
+ )
196
+ end
197
+ end
198
+
199
+ def ensure_x509_svid_stream_started
200
+ return if @x509_stream_thread&.alive?
201
+
202
+ @x509_stream_thread = Thread.new do
203
+ loop do
204
+ begin
205
+ stream_x509_svids
206
+ rescue StandardError => e
207
+ warn "X.509 SVID stream error: #{e.message}"
208
+ sleep 5 # Retry after delay
209
+ end
210
+ break if @shutdown
211
+ end
212
+ end
213
+
214
+ # Wait for first SVID
215
+ deadline = Time.now + @timeout
216
+ until @x509_svid_cache || Time.now > deadline
217
+ sleep 0.1
218
+ end
219
+
220
+ raise Spiffe::Error, 'Timeout waiting for X.509 SVID' unless @x509_svid_cache
221
+ end
222
+
223
+ def stream_x509_svids
224
+ request = Spiffe::Workload::X509SVIDRequest.new
225
+ metadata = { 'workload.spiffe.io' => '1' }
226
+
227
+ stub.fetch_x509_svid(request, metadata: metadata).each do |response|
228
+ process_x509_response(response)
229
+ break if @shutdown
230
+ end
231
+ rescue GRPC::BadStatus => e
232
+ raise Spiffe::Error, "Failed to fetch X.509 SVID: #{e.message}"
233
+ end
234
+
235
+ def process_x509_response(response)
236
+ svids = response.svids.map { |svid_proto| X509SVIDWrapper.from_proto(svid_proto) }
237
+
238
+ @mutex.synchronize do
239
+ @x509_svids_cache = svids
240
+ @x509_svid_cache = svids.first # Default SVID
241
+
242
+ # Notify callbacks
243
+ @x509_update_callbacks&.each do |callback|
244
+ callback.call(@x509_svid_cache)
245
+ rescue StandardError => e
246
+ warn "X.509 SVID update callback error: #{e.message}"
247
+ end
248
+ end
249
+ end
250
+
251
+ def ensure_x509_bundles_stream_started
252
+ return if @x509_bundles_stream_thread&.alive?
253
+
254
+ @x509_bundles_stream_thread = Thread.new do
255
+ loop do
256
+ begin
257
+ stream_x509_bundles
258
+ rescue StandardError => e
259
+ warn "X.509 bundles stream error: #{e.message}"
260
+ sleep 5
261
+ end
262
+ break if @shutdown
263
+ end
264
+ end
265
+
266
+ # Wait for first bundle
267
+ deadline = Time.now + @timeout
268
+ until @x509_bundles_cache || Time.now > deadline
269
+ sleep 0.1
270
+ end
271
+ end
272
+
273
+ def stream_x509_bundles
274
+ request = Spiffe::Workload::X509BundlesRequest.new
275
+ metadata = { 'workload.spiffe.io' => '1' }
276
+
277
+ stub.fetch_x509_bundles(request, metadata: metadata).each do |response|
278
+ process_x509_bundles_response(response)
279
+ break if @shutdown
280
+ end
281
+ rescue GRPC::BadStatus => e
282
+ raise Spiffe::Error, "Failed to fetch X.509 bundles: #{e.message}"
283
+ end
284
+
285
+ def process_x509_bundles_response(response)
286
+ bundles = {}
287
+
288
+ response.bundles.to_h.each do |trust_domain, bundle_data|
289
+ store = OpenSSL::X509::Store.new
290
+
291
+ # Parse all certificates in the bundle
292
+ offset = 0
293
+ while offset < bundle_data.bytesize
294
+ begin
295
+ cert = OpenSSL::X509::Certificate.new(bundle_data[offset..-1])
296
+ store.add_cert(cert)
297
+ offset += cert.to_der.bytesize
298
+ rescue OpenSSL::X509::CertificateError
299
+ break
300
+ end
301
+ end
302
+
303
+ bundles[trust_domain] = store
304
+ end
305
+
306
+ @mutex.synchronize do
307
+ @x509_bundles_cache = bundles
308
+ end
309
+ end
310
+
311
+ def ensure_jwt_bundles_stream_started
312
+ return if @jwt_bundles_stream_thread&.alive?
313
+
314
+ @jwt_bundles_stream_thread = Thread.new do
315
+ loop do
316
+ begin
317
+ stream_jwt_bundles
318
+ rescue StandardError => e
319
+ warn "JWT bundles stream error: #{e.message}"
320
+ sleep 5
321
+ end
322
+ break if @shutdown
323
+ end
324
+ end
325
+
326
+ # Wait for first bundle
327
+ deadline = Time.now + @timeout
328
+ until @jwt_bundles_cache || Time.now > deadline
329
+ sleep 0.1
330
+ end
331
+ end
332
+
333
+ def stream_jwt_bundles
334
+ request = Spiffe::Workload::JWTBundlesRequest.new
335
+
336
+ stub.fetch_jwt_bundles(request).each do |response|
337
+ @mutex.synchronize do
338
+ # Bundles are already in JWKS format
339
+ @jwt_bundles_cache = response.bundles.to_h
340
+ end
341
+ break if @shutdown
342
+ end
343
+ rescue GRPC::BadStatus => e
344
+ raise Spiffe::Error, "Failed to fetch JWT bundles: #{e.message}"
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'grpc'
4
+
5
+ module Spiffe
6
+ module Workload
7
+ # gRPC stub for SPIFFE Workload API
8
+ # This is a simplified stub. Run 'rake generate_protos' to generate
9
+ # the full proto definitions from workload.proto
10
+
11
+ class SpiffeWorkloadAPIStub < GRPC::ClientStub
12
+ # Fetch X.509 SVIDs (streaming RPC)
13
+ # @param request [Hash] Empty request
14
+ # @return [Enumerator] Stream of X509SVIDResponse
15
+ def fetch_x509_svid(request, metadata: {})
16
+ request_marshal = lambda { |req| '' } # Empty request
17
+ response_unmarshal = lambda { |bytes| parse_x509_svid_response(bytes) }
18
+
19
+ request_response_call(
20
+ '/SpiffeWorkloadAPI/FetchX509SVID',
21
+ request,
22
+ request_marshal,
23
+ response_unmarshal,
24
+ return_op: true,
25
+ metadata: metadata
26
+ )
27
+ end
28
+
29
+ # Fetch X.509 bundles (streaming RPC)
30
+ # @param request [Hash] Empty request
31
+ # @return [Enumerator] Stream of X509BundlesResponse
32
+ def fetch_x509_bundles(request, metadata: {})
33
+ request_marshal = lambda { |req| '' }
34
+ response_unmarshal = lambda { |bytes| parse_x509_bundles_response(bytes) }
35
+
36
+ request_response_call(
37
+ '/SpiffeWorkloadAPI/FetchX509Bundles',
38
+ request,
39
+ request_marshal,
40
+ response_unmarshal,
41
+ return_op: true,
42
+ metadata: metadata
43
+ )
44
+ end
45
+
46
+ # Fetch JWT SVID (unary RPC)
47
+ # @param request [Hash] JWTSVIDRequest with audience and optional spiffe_id
48
+ # @return [Hash] JWTSVIDResponse
49
+ def fetch_jwt_svid(request, metadata: {})
50
+ request_marshal = lambda { |req| encode_jwt_svid_request(req) }
51
+ response_unmarshal = lambda { |bytes| parse_jwt_svid_response(bytes) }
52
+
53
+ request_response_call(
54
+ '/SpiffeWorkloadAPI/FetchJWTSVID',
55
+ request,
56
+ request_marshal,
57
+ response_unmarshal,
58
+ return_op: false,
59
+ metadata: metadata
60
+ )
61
+ end
62
+
63
+ # Fetch JWT bundles (streaming RPC)
64
+ # @param request [Hash] Empty request
65
+ # @return [Enumerator] Stream of JWTBundlesResponse
66
+ def fetch_jwt_bundles(request, metadata: {})
67
+ request_marshal = lambda { |req| '' }
68
+ response_unmarshal = lambda { |bytes| parse_jwt_bundles_response(bytes) }
69
+
70
+ request_response_call(
71
+ '/SpiffeWorkloadAPI/FetchJWTBundles',
72
+ request,
73
+ request_marshal,
74
+ response_unmarshal,
75
+ return_op: true,
76
+ metadata: metadata
77
+ )
78
+ end
79
+
80
+ private
81
+
82
+ # Note: These are simplified parsers
83
+ # For production use, replace this file with generated proto stubs
84
+ # by running: rake generate_protos
85
+
86
+ def parse_x509_svid_response(bytes)
87
+ # This is a placeholder that returns a mock structure
88
+ # In reality, this should use google-protobuf to decode
89
+ OpenStruct.new(
90
+ svids: [],
91
+ crl: [],
92
+ federated_bundles: {}
93
+ )
94
+ end
95
+
96
+ def parse_x509_bundles_response(bytes)
97
+ OpenStruct.new(
98
+ crl: [],
99
+ bundles: {}
100
+ )
101
+ end
102
+
103
+ def encode_jwt_svid_request(request)
104
+ # Placeholder encoding
105
+ # Should use google-protobuf to encode
106
+ ''
107
+ end
108
+
109
+ def parse_jwt_svid_response(bytes)
110
+ OpenStruct.new(
111
+ svids: []
112
+ )
113
+ end
114
+
115
+ def parse_jwt_bundles_response(bytes)
116
+ OpenStruct.new(
117
+ bundles: {}
118
+ )
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require_relative 'tls_config'
6
+
7
+ module Spiffe
8
+ module Workload
9
+ # HTTP client with automatic SPIFFE authentication
10
+ class HTTPClient
11
+ attr_reader :client
12
+
13
+ # @param client [Spiffe::Workload::Client] The workload API client
14
+ def initialize(client)
15
+ @client = client
16
+ @tls_context = TLSConfig.create_auto_updating_context(client)
17
+ end
18
+
19
+ # Make an HTTPS request with SPIFFE mTLS
20
+ # @param uri [String, URI] The target URI
21
+ # @param method [Symbol] HTTP method (:get, :post, :put, :delete, etc.)
22
+ # @param body [String, nil] Request body
23
+ # @param headers [Hash] Additional headers
24
+ # @return [Net::HTTPResponse]
25
+ def request(uri, method: :get, body: nil, headers: {})
26
+ uri = URI.parse(uri) if uri.is_a?(String)
27
+
28
+ http = Net::HTTP.new(uri.host, uri.port)
29
+ http.use_ssl = true
30
+ http.ssl_context = @tls_context
31
+
32
+ request_class = case method
33
+ when :get then Net::HTTP::Get
34
+ when :post then Net::HTTP::Post
35
+ when :put then Net::HTTP::Put
36
+ when :delete then Net::HTTP::Delete
37
+ when :head then Net::HTTP::Head
38
+ when :patch then Net::HTTP::Patch
39
+ else raise ArgumentError, "Unsupported method: #{method}"
40
+ end
41
+
42
+ request = request_class.new(uri)
43
+ headers.each { |key, value| request[key] = value }
44
+ request.body = body if body
45
+
46
+ http.request(request)
47
+ end
48
+
49
+ # Convenience methods for common HTTP verbs
50
+ def get(uri, headers: {})
51
+ request(uri, method: :get, headers: headers)
52
+ end
53
+
54
+ def post(uri, body:, headers: {})
55
+ request(uri, method: :post, body: body, headers: headers)
56
+ end
57
+
58
+ def put(uri, body:, headers: {})
59
+ request(uri, method: :put, body: body, headers: headers)
60
+ end
61
+
62
+ def delete(uri, headers: {})
63
+ request(uri, method: :delete, headers: headers)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module Spiffe
7
+ module Workload
8
+ # Represents a JWT SVID
9
+ class JWTSVIDWrapper
10
+ attr_reader :spiffe_id, :token, :hint, :claims
11
+
12
+ # @param spiffe_id [String] The SPIFFE ID
13
+ # @param token [String] The JWT token
14
+ # @param hint [String, nil] Optional hint
15
+ def initialize(spiffe_id:, token:, hint: nil)
16
+ @spiffe_id = spiffe_id
17
+ @token = token
18
+ @hint = hint
19
+ @claims = parse_claims
20
+ end
21
+
22
+ # Parse JWT claims without validation
23
+ # @return [Hash] The JWT claims
24
+ def parse_claims
25
+ # JWT format: header.payload.signature
26
+ parts = @token.split('.')
27
+ raise Error, 'Invalid JWT format' unless parts.length == 3
28
+
29
+ # Decode payload (base64url)
30
+ payload = parts[1]
31
+ # Add padding if necessary
32
+ payload += '=' * (4 - payload.length % 4) if payload.length % 4 != 0
33
+
34
+ decoded = Base64.urlsafe_decode64(payload)
35
+ JSON.parse(decoded)
36
+ rescue StandardError => e
37
+ raise Error, "Failed to parse JWT claims: #{e.message}"
38
+ end
39
+
40
+ # Get the expiration time
41
+ # @return [Time, nil]
42
+ def expiration
43
+ return nil unless @claims['exp']
44
+ Time.at(@claims['exp'])
45
+ end
46
+
47
+ # Check if the JWT is expired
48
+ # @return [Boolean]
49
+ def expired?
50
+ exp = expiration
51
+ return false unless exp
52
+ exp < Time.now
53
+ end
54
+
55
+ # Get audience claims
56
+ # @return [Array<String>]
57
+ def audience
58
+ aud = @claims['aud']
59
+ return [] unless aud
60
+ aud.is_a?(Array) ? aud : [aud]
61
+ end
62
+
63
+ # Parse JWT SVID from proto response
64
+ # @param proto_jwt [Spiffe::Workload::JWTSVID] Proto message
65
+ # @return [JWTSVID]
66
+ def self.from_proto(proto_jwt)
67
+ new(
68
+ spiffe_id: proto_jwt.spiffe_id,
69
+ token: proto_jwt.svid,
70
+ hint: proto_jwt.hint.empty? ? nil : proto_jwt.hint
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end