diff --git a/async-grpc-compatible.gemspec b/async-grpc-compatible.gemspec index c81d9e0..4ebf9bc 100644 --- a/async-grpc-compatible.gemspec +++ b/async-grpc-compatible.gemspec @@ -22,5 +22,6 @@ Gem::Specification.new do |specification| specification.required_ruby_version = ">= 3.3" specification.add_dependency "async-grpc", "~> 0.8" + specification.add_dependency "async-http", "~> 0.100" specification.add_dependency "grpc" end diff --git a/fixtures/async/grpc/compatible/tls_fixture.rb b/fixtures/async/grpc/compatible/tls_fixture.rb new file mode 100644 index 0000000..2baf2cc --- /dev/null +++ b/fixtures/async/grpc/compatible/tls_fixture.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "localhost" + +module Async + module GRPC + module Compatible + module TLSFixture + CERTIFICATE_ISSUER = Localhost::Issuer.new("async-grpc-compatible-test") + SERVER_AUTHORITY = Localhost::Authority.new("localhost", issuer: CERTIFICATE_ISSUER) + CLIENT_AUTHORITY = Localhost::Authority.new("client", issuer: CERTIFICATE_ISSUER) + UNTRUSTED_ISSUER = Localhost::Issuer.new("async-grpc-compatible-untrusted-test") + end + end + end +end diff --git a/gems.rb b/gems.rb index 6f32141..7c36531 100644 --- a/gems.rb +++ b/gems.rb @@ -34,6 +34,7 @@ gem "rubocop-socketry" gem "sus-fixtures-async-http" + gem "localhost" gem "bake-test" gem "bake-test-external" diff --git a/lib/async/grpc/compatible.rb b/lib/async/grpc/compatible.rb index 2a35518..38150f1 100644 --- a/lib/async/grpc/compatible.rb +++ b/lib/async/grpc/compatible.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require_relative "compatible/version" +require_relative "compatible/channel_credentials" require_relative "compatible/client_stub" module Async diff --git a/lib/async/grpc/compatible/channel_credentials.rb b/lib/async/grpc/compatible/channel_credentials.rb new file mode 100644 index 0000000..87540c7 --- /dev/null +++ b/lib/async/grpc/compatible/channel_credentials.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/endpoint" + +module Async + module GRPC + module Compatible + # Represents readable channel credentials which can be translated into a transport-neutral TLS configuration. + class ChannelCredentials + # Initialize channel credentials using the same positional arguments as `GRPC::Core::ChannelCredentials.new`. + # @parameter root_certificates [String | Nil] The trusted root certificates encoded as a PEM bundle. + # @parameter private_key [String | Nil] The client private key encoded as PEM. + # @parameter certificate_chain [String | Nil] The client certificate chain encoded as a PEM bundle. + def initialize(root_certificates = nil, private_key = nil, certificate_chain = nil) + trust_store = if root_certificates + IO::Endpoint::TLS::TrustStore.parse(root_certificates) + end + + certificates = if certificate_chain + IO::Endpoint::TLS::Certificates.parse(certificate_chain) + end + + @tls_configuration = IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificates, + private_key: private_key, + ) + end + + # @attribute [IO::Endpoint::TLS::Configuration] The transport-neutral TLS configuration. + attr_reader :tls_configuration + end + end + end +end diff --git a/lib/async/grpc/compatible/client_stub.rb b/lib/async/grpc/compatible/client_stub.rb index 5202861..91a3b05 100644 --- a/lib/async/grpc/compatible/client_stub.rb +++ b/lib/async/grpc/compatible/client_stub.rb @@ -46,7 +46,7 @@ class ClientStub # Construct a compatible channel. # @parameter channel_override [Channel, Async::GRPC::Client | Nil] An existing compatible channel or client. # @parameter host [String] The gRPC target. - # @parameter credentials [GRPC::Core::ChannelCredentials, Symbol] The channel credentials. + # @parameter credentials [ChannelCredentials, GRPC::Core::ChannelCredentials, Symbol] The channel credentials. # @parameter channel_arguments [Hash] gRPC channel arguments. # @returns [Channel] The compatible channel. def self.setup_channel(channel_override, host, credentials, channel_arguments = {}) @@ -67,7 +67,7 @@ def self.setup_channel(channel_override, host, credentials, channel_arguments = # Construct an HTTP/2 endpoint for a gRPC target. # @parameter host [String] The gRPC target. - # @parameter credentials [GRPC::Core::ChannelCredentials, Symbol] The channel credentials. + # @parameter credentials [ChannelCredentials, GRPC::Core::ChannelCredentials, Symbol] The channel credentials. # @parameter channel_arguments [Hash] gRPC channel arguments. # @returns [Async::HTTP::Endpoint] The HTTP/2 endpoint. def self.endpoint_for(host, credentials, channel_arguments = {}) @@ -82,22 +82,38 @@ def self.endpoint_for(host, credentials, channel_arguments = {}) url = "#{scheme}://#{target}" end - Async::HTTP::Endpoint.parse(url, protocol: Async::HTTP::Protocol::HTTP2) + Async::HTTP::Endpoint.parse( + url, + protocol: Async::HTTP::Protocol::HTTP2, + tls_configuration: tls_configuration_for(credentials), + ) end # Determine the URL scheme for the given credentials. - # @parameter credentials [GRPC::Core::ChannelCredentials, Symbol] The channel credentials. + # @parameter credentials [ChannelCredentials, GRPC::Core::ChannelCredentials, Symbol] The channel credentials. # @returns [String] Either `"http"` or `"https"`. def self.scheme_for(credentials) return "http" if credentials == INSECURE_CREDENTIALS - if credentials.is_a?(::GRPC::Core::ChannelCredentials) + if credentials.is_a?(ChannelCredentials) || credentials.is_a?(::GRPC::Core::ChannelCredentials) return "https" end raise TypeError, "credentials must be GRPC channel credentials or :this_channel_is_insecure" end + # Extract a transport-neutral TLS configuration from readable compatible credentials. + # Native grpc-ruby credentials are opaque, so they continue to use the default Async HTTP TLS configuration. + # @parameter credentials [ChannelCredentials, GRPC::Core::ChannelCredentials, Symbol] The channel credentials. + # @returns [IO::Endpoint::TLS::Configuration | Nil] The TLS configuration, if available. + def self.tls_configuration_for(credentials) + if credentials.is_a?(ChannelCredentials) + return credentials.tls_configuration + end + + return nil + end + # Normalize a grpc-ruby target into an HTTP authority. # @parameter host [String] The gRPC target. # @returns [String] The normalized target. @@ -115,7 +131,7 @@ def self.normalize_target(host) # Create a compatible client stub. # @parameter host [String] The gRPC target. - # @parameter credentials [GRPC::Core::ChannelCredentials, Symbol, Nil] The channel credentials. + # @parameter credentials [ChannelCredentials, GRPC::Core::ChannelCredentials, Symbol, Nil] The channel credentials. # @parameter channel_override [Channel, Async::GRPC::Client | Nil] An existing compatible channel or client. # @parameter timeout [Numeric | Nil] The default relative timeout in seconds. # @parameter propagate_mask [Integer | Nil] Reserved for grpc-ruby compatibility. diff --git a/readme.md b/readme.md index 1211a6b..f01dd24 100644 --- a/readme.md +++ b/readme.md @@ -14,7 +14,8 @@ Select the compatible stub when constructing a generated client: require "async/grpc/compatible" stub_class = Async::GRPC::Compatible::ClientStub -stub = stub_class.new("grpc.example.com:443", GRPC::Core::ChannelCredentials.new) +credentials = Async::GRPC::Compatible::ChannelCredentials.new +stub = stub_class.new("grpc.example.com:443", credentials) response = stub.request_response( "/example.Service/Get", @@ -35,6 +36,18 @@ end That configuration API is illustrative and will require a corresponding NuevoProtobuf change. +Custom trust roots and mutual TLS use the same positional credential arguments as grpc-ruby, while retaining the certificate material so it can be translated into `IO::Endpoint::TLS::Configuration`: + +``` ruby +credentials = Async::GRPC::Compatible::ChannelCredentials.new( + root_certificates, + client_private_key, + client_certificate_chain, +) +``` + +Native `GRPC::Core::ChannelCredentials` remain accepted for ordinary TLS. grpc-ruby does not expose the certificate material stored in native credentials, so custom roots and client identities must use the compatible credential class. + ## Current Compatibility The initial implementation supports: @@ -44,6 +57,7 @@ The initial implementation supports: - Custom marshal and unmarshal callables. - Request metadata and deadlines. - Insecure and standard TLS endpoints. + - Custom TLS root certificates and mutual TLS client identities. - Translation of gRPC failures into `GRPC::BadStatus` subclasses. The following are not yet supported: @@ -52,7 +66,7 @@ The following are not yet supported: - Client, server, or bidirectional streaming. - grpc-ruby interceptors. - Parent call propagation and per-call credentials. - - Custom TLS root certificates, client certificates, and native channel overrides. + - Native channel overrides. - grpc-ruby channel arguments beyond accepting the compatible constructor parameter. - Non-DNS resolvers such as Unix sockets and xDS. diff --git a/releases.md b/releases.md index ad7a7c3..2f5ae1c 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add transport-neutral custom trust root and mutual TLS channel credentials. + ## v0.0.0 - Initial implementation of an Async-backed `GRPC::ClientStub` compatible unary client. diff --git a/test/async/grpc/compatible/channel_credentials.rb b/test/async/grpc/compatible/channel_credentials.rb new file mode 100644 index 0000000..6c08d9e --- /dev/null +++ b/test/async/grpc/compatible/channel_credentials.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/grpc/compatible" +require "async/grpc/compatible/tls_fixture" + +describe Async::GRPC::Compatible::ChannelCredentials do + let(:certificate_issuer) {Async::GRPC::Compatible::TLSFixture::CERTIFICATE_ISSUER} + let(:client_authority) {Async::GRPC::Compatible::TLSFixture::CLIENT_AUTHORITY} + let(:root_certificates) {certificate_issuer.certificate.to_pem} + let(:private_key) {client_authority.key.to_pem} + let(:certificate_chain) {client_authority.certificate.to_pem + root_certificates} + + it "translates grpc-ruby positional arguments into transport-neutral TLS configuration" do + credentials = subject.new(root_certificates, private_key, certificate_chain) + configuration = credentials.tls_configuration + + expect(configuration.trust_store.certificates).to be == [root_certificates.strip] + expect(configuration.certificate_chain).to be == [ + client_authority.certificate.to_pem.strip, + root_certificates.strip, + ] + expect(configuration.private_key).to be == private_key + expect(configuration.verification).to be == :peer + end + + it "supports the default secure channel credential shape" do + configuration = subject.new.tls_configuration + + expect(configuration.trust_store).to be_nil + expect(configuration.certificate_chain).to be_nil + expect(configuration.private_key).to be_nil + end + + it "rejects an incomplete client identity" do + expect do + subject.new(root_certificates, private_key) + end.to raise_exception(ArgumentError, message: be =~ /certificate chain and private key/i) + end + + it "rejects an invalid certificate bundle" do + expect do + subject.new("not a certificate") + end.to raise_exception(ArgumentError, message: be =~ /does not contain any certificates/i) + end +end diff --git a/test/async/grpc/compatible/client_stub.rb b/test/async/grpc/compatible/client_stub.rb index 90d84cb..937c430 100644 --- a/test/async/grpc/compatible/client_stub.rb +++ b/test/async/grpc/compatible/client_stub.rb @@ -333,6 +333,14 @@ def request(value, **options) expect(endpoint.to_url.to_s).to be == "https://grpc.example.com/" expect(endpoint.protocol).to be == Async::HTTP::Protocol::HTTP2 + expect(endpoint.tls_configuration).to be_nil + end + + it "forwards readable compatible channel credentials" do + credentials = Async::GRPC::Compatible::ChannelCredentials.new + endpoint = subject.endpoint_for("grpc.example.com:443", credentials) + + expect(endpoint.tls_configuration).to be_equal(credentials.tls_configuration) end it "rejects invalid credentials" do diff --git a/test/async/grpc/compatible/tls.rb b/test/async/grpc/compatible/tls.rb new file mode 100644 index 0000000..67b7b9c --- /dev/null +++ b/test/async/grpc/compatible/tls.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "async/grpc/compatible" +require "async/grpc/compatible/tls_fixture" +require "async/grpc/dispatcher" +require "async/grpc/service" +require "sus/fixtures/async/http" +require "uri" + +class TLSCompatibleMessage + def self.decode(payload) + new(payload) + end + + def initialize(value) + @value = value + end + + attr_reader :value + + def to_proto + @value + end +end + +class TLSCompatibleInterface < Protocol::GRPC::Interface + rpc :Echo, + request_class: TLSCompatibleMessage, + response_class: TLSCompatibleMessage, + streaming: :unary +end + +class TLSCompatibleService < Async::GRPC::Service + def echo(input, output, _call) + output.write(input.read) + end +end + +describe "compatible TLS channel credentials" do + include Sus::Fixtures::Async::HTTP::ServerContext + + let(:protocol) {Async::HTTP::Protocol::HTTP2} + let(:url) {"https://localhost:0"} + let(:service_name) {"compatible.TLSService"} + let(:service) {TLSCompatibleService.new(TLSCompatibleInterface, service_name)} + let(:app) {Async::GRPC::Dispatcher.new(services: {service_name => service})} + + let(:certificate_issuer) {Async::GRPC::Compatible::TLSFixture::CERTIFICATE_ISSUER} + let(:server_authority) {Async::GRPC::Compatible::TLSFixture::SERVER_AUTHORITY} + def build_server_tls_context + server_authority.server_context.tap do |context| + context.alpn_protocols = protocol.names + end + end + + let(:server_tls_context) {build_server_tls_context} + + def endpoint_options + super.merge(ssl_context: server_tls_context) + end + + def secure_target + "localhost:#{URI(bound_url).port}" + end + + def request_with(credentials) + client_stub = Async::GRPC::Compatible::ClientStub.new(secure_target, credentials) + response = client_stub.request_response( + "/#{service_name}/Echo", + TLSCompatibleMessage.new("Hello"), + ->(message){message.to_proto}, + TLSCompatibleMessage.method(:decode), + ) + + return response + ensure + client_stub&.close + end + + it "trusts a server using custom root certificates" do + credentials = Async::GRPC::Compatible::ChannelCredentials.new(certificate_issuer.certificate.to_pem) + + expect(request_with(credentials).value).to be == "Hello" + end + + it "rejects a server signed by an untrusted certificate authority" do + untrusted_issuer = Async::GRPC::Compatible::TLSFixture::UNTRUSTED_ISSUER + credentials = Async::GRPC::Compatible::ChannelCredentials.new(untrusted_issuer.certificate.to_pem) + + expect do + request_with(credentials) + end.to raise_exception(OpenSSL::SSL::SSLError) + end + + with "a server which requires a client certificate" do + let(:client_authority) {Async::GRPC::Compatible::TLSFixture::CLIENT_AUTHORITY} + let(:server_tls_context) do + build_server_tls_context.tap do |context| + context.cert_store = server_authority.store + context.verify_mode = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + end + end + + it "authenticates using a client certificate and private key" do + credentials = Async::GRPC::Compatible::ChannelCredentials.new( + certificate_issuer.certificate.to_pem, + client_authority.key.to_pem, + client_authority.certificate.to_pem + certificate_issuer.certificate.to_pem, + ) + + expect(request_with(credentials).value).to be == "Hello" + end + + it "rejects a client without a certificate" do + credentials = Async::GRPC::Compatible::ChannelCredentials.new(certificate_issuer.certificate.to_pem) + + begin + request_with(credentials) + rescue OpenSSL::SSL::SSLError, EOFError => error + expect([OpenSSL::SSL::SSLError, EOFError]).to be(:include?, error.class) + else + expect(false).to be == true + end + end + end +end