From 200dd78f60f6e66f7d2ba6cbb5c7a1a8d4b73c1e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:05:16 +1200 Subject: [PATCH 01/10] Add transport-neutral TLS configuration Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- context/getting-started.md | 31 +++++++++ guides/getting-started/readme.md | 31 +++++++++ lib/io/endpoint.rb | 3 + lib/io/endpoint/ssl_endpoint.rb | 17 ++++- lib/io/endpoint/tls/configuration.rb | 61 +++++++++++++++++ lib/io/endpoint/tls/openssl.rb | 59 ++++++++++++++++ lib/io/endpoint/tls/trust_store.rb | 82 +++++++++++++++++++++++ test/io/endpoint/ssl_endpoint.rb | 54 +++++++++++++++ test/io/endpoint/tls/configuration.rb | 65 ++++++++++++++++++ test/io/endpoint/tls/openssl.rb | 30 +++++++++ test/io/endpoint/tls/trust_store.rb | 96 +++++++++++++++++++++++++++ 11 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 lib/io/endpoint/tls/configuration.rb create mode 100644 lib/io/endpoint/tls/openssl.rb create mode 100644 lib/io/endpoint/tls/trust_store.rb create mode 100644 test/io/endpoint/tls/configuration.rb create mode 100644 test/io/endpoint/tls/openssl.rb create mode 100644 test/io/endpoint/tls/trust_store.rb diff --git a/context/getting-started.md b/context/getting-started.md index 1d967c3..34114a3 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -111,3 +111,34 @@ endpoint.connect do |socket| puts response end ``` + +### Configuring TLS Certificate Material + +Use {ruby IO::Endpoint::TLS::Configuration} to provide certificate and private key material without coupling application configuration to OpenSSL. Certificate material is supplied as PEM content rather than file paths, so it can be loaded from files, environment variables, or secret stores: + +```ruby +trust_store = IO::Endpoint::TLS::TrustStore.parse(root_certificates) + +tls_configuration = IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificate_chain, + private_key: private_key, +) + +endpoint = IO::Endpoint.ssl( + "example.com", + 443, + hostname: "example.com", + tls_configuration: tls_configuration, +) +``` + +Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. + +Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. + +The SSL endpoint converts the trust store into an OpenSSL certificate store and the complete configuration into an OpenSSL context. Other endpoint implementations can consume the same trust, identity, and verification configuration using their native TLS implementation. + +The OpenSSL conversion is also available directly using `IO::Endpoint::TLS::OpenSSL.build_certificate_store(trust_store)`. + +For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 1d967c3..34114a3 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -111,3 +111,34 @@ endpoint.connect do |socket| puts response end ``` + +### Configuring TLS Certificate Material + +Use {ruby IO::Endpoint::TLS::Configuration} to provide certificate and private key material without coupling application configuration to OpenSSL. Certificate material is supplied as PEM content rather than file paths, so it can be loaded from files, environment variables, or secret stores: + +```ruby +trust_store = IO::Endpoint::TLS::TrustStore.parse(root_certificates) + +tls_configuration = IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificate_chain, + private_key: private_key, +) + +endpoint = IO::Endpoint.ssl( + "example.com", + 443, + hostname: "example.com", + tls_configuration: tls_configuration, +) +``` + +Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. + +Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. + +The SSL endpoint converts the trust store into an OpenSSL certificate store and the complete configuration into an OpenSSL context. Other endpoint implementations can consume the same trust, identity, and verification configuration using their native TLS implementation. + +The OpenSSL conversion is also available directly using `IO::Endpoint::TLS::OpenSSL.build_certificate_store(trust_store)`. + +For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. diff --git a/lib/io/endpoint.rb b/lib/io/endpoint.rb index a0afa15..52fe1a5 100644 --- a/lib/io/endpoint.rb +++ b/lib/io/endpoint.rb @@ -6,6 +6,9 @@ require_relative "endpoint/version" require_relative "endpoint/generic" require_relative "endpoint/shared_endpoint" +require_relative "endpoint/tls/trust_store" +require_relative "endpoint/tls/configuration" +require_relative "endpoint/tls/openssl" # Represents a collection of endpoint classes for network I/O operations. module IO::Endpoint diff --git a/lib/io/endpoint/ssl_endpoint.rb b/lib/io/endpoint/ssl_endpoint.rb index 7dc8b36..516276a 100644 --- a/lib/io/endpoint/ssl_endpoint.rb +++ b/lib/io/endpoint/ssl_endpoint.rb @@ -5,6 +5,7 @@ require_relative "host_endpoint" require_relative "generic" +require_relative "tls/openssl" require "openssl" @@ -31,6 +32,7 @@ class SSLEndpoint < Generic # Initialize a new SSL endpoint. # @parameter endpoint [Generic] The underlying endpoint to wrap with SSL. # @option ssl_context [OpenSSL::SSL::SSLContext, nil] An optional SSL context to use. + # @option tls_configuration [TLS::Configuration, nil] Transport-neutral TLS certificate and verification configuration. # @parameter options [Hash] Additional options including `:ssl_params` and `:hostname`. def initialize(endpoint, **options) super(**options) @@ -79,6 +81,12 @@ def params @options[:ssl_params] end + # Get the transport-neutral TLS configuration from options. + # @returns [TLS::Configuration, nil] The TLS configuration if specified. + def tls_configuration + @options[:tls_configuration] + end + # Build an SSL context with configured parameters. # @parameter context [OpenSSL::SSL::SSLContext] An optional SSL context to configure. # @returns [OpenSSL::SSL::SSLContext] The configured SSL context. @@ -87,6 +95,10 @@ def build_context(context = ::OpenSSL::SSL::SSLContext.new) context.set_params(params) end + if tls_configuration = self.tls_configuration + TLS::OpenSSL.apply(context, tls_configuration) + end + # context.setup # context.freeze @@ -175,11 +187,12 @@ def each # @parameter arguments # @parameter ssl_context [OpenSSL::SSL::SSLContext, nil] + # @parameter tls_configuration [TLS::Configuration, nil] # @parameter hostname [String, nil] # @parameter options keyword arguments passed through to {Endpoint.tcp} # # @returns [SSLEndpoint] - def self.ssl(*arguments, ssl_context: nil, hostname: nil, **options) - SSLEndpoint.new(self.tcp(*arguments, **options), ssl_context: ssl_context, hostname: hostname) + def self.ssl(*arguments, ssl_context: nil, tls_configuration: nil, hostname: nil, **options) + SSLEndpoint.new(self.tcp(*arguments, **options), ssl_context: ssl_context, tls_configuration: tls_configuration, hostname: hostname) end end diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb new file mode 100644 index 0000000..6f654f0 --- /dev/null +++ b/lib/io/endpoint/tls/configuration.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "trust_store" + +module IO::Endpoint + # @namespace + module TLS + # Represents transport-neutral TLS certificate and verification configuration. + class Configuration + # Initialize a TLS configuration from PEM-encoded certificate and private key material. + # @parameter trust_store [TrustStore | Nil] The trusted certificate sources. + # @parameter certificate_chain [String | Nil] The local certificate chain encoded as PEM, with the leaf certificate first. + # @parameter private_key [String | Nil] The private key encoded as PEM. + # @parameter verification [Symbol | Nil] The peer verification policy: `:none`, `:peer`, or `:required`. When omitted, `:peer` is used if a trust store is provided. + # @raises [ArgumentError] If the certificate chain and private key are not provided together, or the verification policy is invalid. + def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verification: nil) + if certificate_chain.nil? != private_key.nil? + raise ArgumentError, "The certificate chain and private key must be provided together!" + end + + verification ||= :peer if trust_store + unless [nil, :none, :peer, :required].include?(verification) + raise ArgumentError, "Unsupported verification policy: #{verification.inspect}!" + end + + @trust_store = trust_store + @certificate_chain = certificate_chain + @private_key = private_key + @verification = verification + end + + # @attribute [TrustStore | Nil] The trusted certificate sources. + attr :trust_store + + # @attribute [String | Nil] The local certificate chain encoded as PEM, with the leaf certificate first. + attr :certificate_chain + + # @attribute [String | Nil] The private key encoded as PEM. + attr :private_key + + # @attribute [Symbol | Nil] The peer verification policy. + attr :verification + + # Get a representation of the configuration without exposing certificate or private key material. + # @returns [String] A redacted representation of the configuration. + def inspect + attributes = { + trust_store: !@trust_store.nil?, + certificate_chain: !@certificate_chain.nil?, + private_key: !@private_key.nil?, + verification: @verification, + } + + return "\#<#{self.class} #{attributes.inspect}>" + end + end + end +end diff --git a/lib/io/endpoint/tls/openssl.rb b/lib/io/endpoint/tls/openssl.rb new file mode 100644 index 0000000..49702c3 --- /dev/null +++ b/lib/io/endpoint/tls/openssl.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require_relative "trust_store" +require_relative "configuration" + +require "openssl" + +module IO::Endpoint + module TLS + # Provides OpenSSL compilation for transport-neutral TLS configuration. + module OpenSSL + # Build an OpenSSL certificate store from transport-neutral trusted certificate configuration. + # @parameter trust_store [TrustStore] The trusted certificate sources. + # @returns [OpenSSL::X509::Store] The configured OpenSSL certificate store. + def self.build_certificate_store(trust_store) + ::OpenSSL::X509::Store.new.tap do |store| + store.set_default_paths if trust_store.system_certificates? + + trust_store.certificates.each do |certificate_bundle| + ::OpenSSL::X509::Certificate.load(certificate_bundle).each do |certificate| + store.add_cert(certificate) + end + end + end + end + + # Apply transport-neutral TLS configuration to an OpenSSL context. + # @parameter context [OpenSSL::SSL::SSLContext] The OpenSSL context to configure. + # @parameter configuration [Configuration] The transport-neutral TLS configuration. + # @returns [OpenSSL::SSL::SSLContext] The configured OpenSSL context. + def self.apply(context, configuration) + if trust_store = configuration.trust_store + context.cert_store = build_certificate_store(trust_store) + end + + if certificate_chain = configuration.certificate_chain + certificates = ::OpenSSL::X509::Certificate.load(certificate_chain) + context.cert = certificates.shift + context.extra_chain_cert = certificates unless certificates.empty? + context.key = ::OpenSSL::PKey.read(configuration.private_key) + end + + case configuration.verification + when :none + context.verify_mode = ::OpenSSL::SSL::VERIFY_NONE + when :peer + context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER + when :required + context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER | ::OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + end + + return context + end + end + end +end diff --git a/lib/io/endpoint/tls/trust_store.rb b/lib/io/endpoint/tls/trust_store.rb new file mode 100644 index 0000000..12ade08 --- /dev/null +++ b/lib/io/endpoint/tls/trust_store.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module IO::Endpoint + # @namespace + module TLS + # Represents transport-neutral trusted certificate sources. + class TrustStore + # Matches individual certificates in a PEM bundle. + CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m + private_constant :CERTIFICATE_PATTERN + + # Load a PEM-encoded certificate bundle from the given path. + # @parameter path [String | Interface(:to_path)] The path to the certificate bundle. + # @parameter options [Hash] Options forwarded to {.parse}. + # @returns [TrustStore] The loaded trust store. + def self.load(path, **options) + return parse(File.read(path), **options) + end + + # Parse a PEM-encoded certificate bundle into a trust store. + # @parameter certificate_bundle [String] One or more trusted certificates encoded as PEM. + # @parameter system_certificates [Boolean] Whether system-provided trusted certificates should be included. + # @returns [TrustStore] The parsed trust store. + # @raises [ArgumentError] If the bundle does not contain any certificates. + # @raises [TypeError] If the bundle is not a string. + def self.parse(certificate_bundle, system_certificates: false) + unless certificate_bundle.is_a?(String) + raise TypeError, "The certificate bundle must be provided as a string!" + end + + certificates = certificate_bundle.scan(CERTIFICATE_PATTERN) + + unless certificates.any? + raise ArgumentError, "The certificate bundle does not contain any certificates!" + end + + return self.new(certificates: certificates, system_certificates: system_certificates) + end + + # Initialize a trust store from PEM-encoded trusted certificates. + # @parameter certificates [Array(String)] The trusted certificates encoded as PEM. + # @parameter system_certificates [Boolean] Whether system-provided trusted certificates should be included. + # @raises [ArgumentError] If no source of trusted certificates is specified. + # @raises [TypeError] If certificates are not provided as strings. + def initialize(certificates: [], system_certificates: false) + unless certificates.is_a?(Array) && certificates.all?{|certificate| certificate.is_a?(String)} + raise TypeError, "Certificates must be provided as an array of strings!" + end + + unless certificates.any? || system_certificates + raise ArgumentError, "At least one source of trusted certificates must be specified!" + end + + @certificates = certificates + @system_certificates = system_certificates + end + + # @attribute [Array(String)] The trusted certificate bundles encoded as PEM. + attr :certificates + + # Whether system-provided trusted certificates should be included. + # @returns [Boolean] `true` if system-provided trusted certificates should be included. + def system_certificates? + @system_certificates + end + + # Get a representation of the trust store without exposing certificate material. + # @returns [String] A redacted representation of the trust store. + def inspect + attributes = { + certificates: @certificates.size, + system_certificates: @system_certificates, + } + + return "\#<#{self.class} #{attributes.inspect}>" + end + end + end +end diff --git a/test/io/endpoint/ssl_endpoint.rb b/test/io/endpoint/ssl_endpoint.rb index a556449..6485c39 100644 --- a/test/io/endpoint/ssl_endpoint.rb +++ b/test/io/endpoint/ssl_endpoint.rb @@ -48,6 +48,60 @@ def client_endpoint(address) end end + with "mutual TLS configuration" do + include Sus::Fixtures::OpenSSL::ValidCertificateContext + + let(:trust_store) do + IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) + end + let(:certificate_chain) {certificate.to_pem + certificate_authority_certificate.to_pem} + let(:private_key) {key.to_pem} + + let(:server_tls_configuration) do + IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificate_chain, + private_key: private_key, + verification: :required, + ) + end + + let(:client_tls_configuration) do + IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificate_chain, + private_key: private_key, + ) + end + + let(:endpoint) {IO::Endpoint.tcp("localhost", 0)} + let(:server_endpoint) {subject.new(endpoint, tls_configuration: server_tls_configuration)} + + def client_endpoint(address) + endpoint = IO::Endpoint::AddressEndpoint.new(address) + return subject.new(endpoint, tls_configuration: client_tls_configuration, hostname: "localhost") + end + + it "authenticates both peers" do + bound = server_endpoint.bound + + bound.bind do |server| + peer, address = server.accept + peer.accept + expect(peer.peer_cert.to_der).to be == certificate.to_der + peer.close + end + + bound.sockets.each do |server| + client_endpoint(server.local_address).connect do |client| + expect(client.peer_cert.to_der).to be == certificate.to_der + end + end + ensure + bound&.close + end + end + with "invalid certificates" do include Sus::Fixtures::OpenSSL::InvalidCertificateContext include Sus::Fixtures::OpenSSL::VerifiedCertificateContext diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb new file mode 100644 index 0000000..701960b --- /dev/null +++ b/test/io/endpoint/tls/configuration.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/endpoint/tls/configuration" + +describe IO::Endpoint::TLS::Configuration do + let(:certificate) {"trusted certificate"} + let(:certificates) {[certificate]} + let(:trust_store) {IO::Endpoint::TLS::TrustStore.new(certificates: certificates)} + let(:certificate_chain) {"certificate chain"} + let(:private_key) {"private key"} + + with "certificate material" do + let(:configuration) do + subject.new( + trust_store: trust_store, + certificate_chain: certificate_chain, + private_key: private_key, + ) + end + + it "retains the supplied certificate material" do + expect(configuration.trust_store).to be == trust_store + expect(configuration.certificate_chain).to be_equal(certificate_chain) + expect(configuration.private_key).to be_equal(private_key) + end + + it "verifies peers by default when a trust store is provided" do + expect(configuration.verification).to be == :peer + end + + it "does not expose certificate or private key material when inspected" do + representation = configuration.inspect + + expect(representation).not.to be(:include?, certificate) + expect(representation).not.to be(:include?, certificate_chain) + expect(representation).not.to be(:include?, private_key) + expect(representation).to be(:include?, "private_key") + end + end + + with "incomplete local identity" do + it "rejects a certificate chain without a private key" do + expect do + subject.new(certificate_chain: certificate_chain) + end.to raise_exception(ArgumentError, message: be =~ /provided together/) + end + + it "rejects a private key without a certificate chain" do + expect do + subject.new(private_key: private_key) + end.to raise_exception(ArgumentError, message: be =~ /provided together/) + end + end + + with "an unsupported verification policy" do + it "rejects the policy" do + expect do + subject.new(verification: :unsupported) + end.to raise_exception(ArgumentError, message: be =~ /Unsupported verification policy/) + end + end +end diff --git a/test/io/endpoint/tls/openssl.rb b/test/io/endpoint/tls/openssl.rb new file mode 100644 index 0000000..9c4e7d8 --- /dev/null +++ b/test/io/endpoint/tls/openssl.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/endpoint/tls/openssl" +require "sus/fixtures/openssl" + +describe IO::Endpoint::TLS::OpenSSL do + include Sus::Fixtures::OpenSSL::ValidCertificateContext + + with ".build_certificate_store" do + it "builds an OpenSSL certificate store from trusted certificates" do + trust_store = IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) + + openssl_certificate_store = subject.build_certificate_store(trust_store) + + expect(openssl_certificate_store).to be_a(::OpenSSL::X509::Store) + expect(openssl_certificate_store.verify(certificate)).to be_truthy + end + + it "builds an OpenSSL certificate store from system certificates" do + trust_store = IO::Endpoint::TLS::TrustStore.new(system_certificates: true) + + openssl_certificate_store = subject.build_certificate_store(trust_store) + + expect(openssl_certificate_store).to be_a(::OpenSSL::X509::Store) + end + end +end diff --git a/test/io/endpoint/tls/trust_store.rb b/test/io/endpoint/tls/trust_store.rb new file mode 100644 index 0000000..7e443b4 --- /dev/null +++ b/test/io/endpoint/tls/trust_store.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/endpoint/tls/trust_store" +require "with_temporary_directory" + +describe IO::Endpoint::TLS::TrustStore do + let(:certificate) {"trusted certificate"} + let(:certificates) {[certificate]} + + with "custom certificates" do + let(:trust_store) {subject.new(certificates: certificates)} + + it "retains the supplied certificate material" do + expect(trust_store.certificates).to be_equal(certificates) + end + + it "does not expose certificate material when inspected" do + representation = trust_store.inspect + + expect(representation).not.to be(:include?, certificate) + expect(representation).to be(:include?, "certificates") + end + end + + with ".load" do + include WithTemporaryDirectory + + let(:certificate) {"-----BEGIN CERTIFICATE-----\ntrusted\n-----END CERTIFICATE-----"} + let(:path) {File.join(temporary_directory, "certificates.pem")} + + it "loads and parses a certificate bundle" do + File.write(path, certificate) + + trust_store = subject.load(path, system_certificates: true) + + expect(trust_store.certificates).to be == [certificate] + expect(trust_store).to be(:system_certificates?) + end + + it "propagates file loading errors" do + expect do + subject.load(path) + end.to raise_exception(Errno::ENOENT) + end + end + + with ".parse" do + let(:first_certificate) {"-----BEGIN CERTIFICATE-----\nfirst\n-----END CERTIFICATE-----"} + let(:second_certificate) {"-----BEGIN CERTIFICATE-----\nsecond\n-----END CERTIFICATE-----"} + + it "splits a certificate bundle" do + trust_store = subject.parse("#{first_certificate}\n#{second_certificate}\n") + + expect(trust_store.certificates).to be == [first_certificate, second_certificate] + end + + it "rejects a bundle without certificates" do + expect do + subject.parse("not a certificate bundle") + end.to raise_exception(ArgumentError, message: be =~ /does not contain any certificates/) + end + + it "rejects a bundle which is not a string" do + expect do + subject.parse(Object.new) + end.to raise_exception(TypeError, message: be =~ /must be provided as a string/) + end + end + + with "system certificates" do + it "can include them" do + trust_store = subject.new(system_certificates: true) + + expect(trust_store).to be(:system_certificates?) + end + end + + with "no trusted certificate source" do + it "is rejected" do + expect do + subject.new + end.to raise_exception(ArgumentError, message: be =~ /At least one source/) + end + end + + with "an unsupported certificate representation" do + it "is rejected" do + expect do + subject.new(certificates: certificate) + end.to raise_exception(TypeError, message: be =~ /array of strings/) + end + end +end From 3b5731b8cd41d930bd3c70ea604b63233c11f76a Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:44:12 +1200 Subject: [PATCH 02/10] Avoid loading OpenSSL from the generic entry point Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- lib/io/endpoint.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/io/endpoint.rb b/lib/io/endpoint.rb index 52fe1a5..0e05c8a 100644 --- a/lib/io/endpoint.rb +++ b/lib/io/endpoint.rb @@ -8,7 +8,6 @@ require_relative "endpoint/shared_endpoint" require_relative "endpoint/tls/trust_store" require_relative "endpoint/tls/configuration" -require_relative "endpoint/tls/openssl" # Represents a collection of endpoint classes for network I/O operations. module IO::Endpoint From 20ce3fb5d355e1b1541b2cac5ab5c9f9f0e8ab90 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:44:48 +1200 Subject: [PATCH 03/10] Validate and test TLS verification policies Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- lib/io/endpoint/tls/configuration.rb | 2 +- lib/io/endpoint/tls/openssl.rb | 4 +- test/io/endpoint/tls/configuration.rb | 6 +++ test/io/endpoint/tls/openssl.rb | 61 ++++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb index 6f654f0..ae0345c 100644 --- a/lib/io/endpoint/tls/configuration.rb +++ b/lib/io/endpoint/tls/configuration.rb @@ -21,7 +21,7 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif raise ArgumentError, "The certificate chain and private key must be provided together!" end - verification ||= :peer if trust_store + verification = :peer if verification.nil? && trust_store unless [nil, :none, :peer, :required].include?(verification) raise ArgumentError, "Unsupported verification policy: #{verification.inspect}!" end diff --git a/lib/io/endpoint/tls/openssl.rb b/lib/io/endpoint/tls/openssl.rb index 49702c3..425fe59 100644 --- a/lib/io/endpoint/tls/openssl.rb +++ b/lib/io/endpoint/tls/openssl.rb @@ -19,8 +19,8 @@ def self.build_certificate_store(trust_store) ::OpenSSL::X509::Store.new.tap do |store| store.set_default_paths if trust_store.system_certificates? - trust_store.certificates.each do |certificate_bundle| - ::OpenSSL::X509::Certificate.load(certificate_bundle).each do |certificate| + trust_store.certificates.each do |certificate_pem| + ::OpenSSL::X509::Certificate.load(certificate_pem).each do |certificate| store.add_cert(certificate) end end diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 701960b..4c2bc7e 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -61,5 +61,11 @@ subject.new(verification: :unsupported) end.to raise_exception(ArgumentError, message: be =~ /Unsupported verification policy/) end + + it "does not replace a false policy when a trust store is provided" do + expect do + subject.new(trust_store: trust_store, verification: false) + end.to raise_exception(ArgumentError, message: be =~ /Unsupported verification policy/) + end end end diff --git a/test/io/endpoint/tls/openssl.rb b/test/io/endpoint/tls/openssl.rb index 9c4e7d8..580fb8c 100644 --- a/test/io/endpoint/tls/openssl.rb +++ b/test/io/endpoint/tls/openssl.rb @@ -9,10 +9,12 @@ describe IO::Endpoint::TLS::OpenSSL do include Sus::Fixtures::OpenSSL::ValidCertificateContext + let(:trust_store) do + IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) + end + with ".build_certificate_store" do it "builds an OpenSSL certificate store from trusted certificates" do - trust_store = IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) - openssl_certificate_store = subject.build_certificate_store(trust_store) expect(openssl_certificate_store).to be_a(::OpenSSL::X509::Store) @@ -27,4 +29,59 @@ expect(openssl_certificate_store).to be_a(::OpenSSL::X509::Store) end end + + with ".apply" do + let(:context) {::OpenSSL::SSL::SSLContext.new} + + it "applies the trust store and local identity" do + configuration = IO::Endpoint::TLS::Configuration.new( + trust_store: trust_store, + certificate_chain: certificate.to_pem + certificate_authority_certificate.to_pem, + private_key: key.to_pem, + ) + + result = subject.apply(context, configuration) + + expect(result).to be_equal(context) + expect(context.cert_store.verify(certificate)).to be_truthy + expect(context.cert.to_der).to be == certificate.to_der + expect(context.extra_chain_cert.map(&:to_der)).to be == [certificate_authority_certificate.to_der] + expect(context.key.public_to_der).to be == key.public_to_der + end + + it "preserves the verification mode when no policy is specified" do + context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER + configuration = IO::Endpoint::TLS::Configuration.new + + subject.apply(context, configuration) + + expect(context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_PEER + end + + it "disables peer verification when requested" do + context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER + configuration = IO::Endpoint::TLS::Configuration.new(verification: :none) + + subject.apply(context, configuration) + + expect(context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_NONE + end + + it "enables peer verification" do + configuration = IO::Endpoint::TLS::Configuration.new(verification: :peer) + + subject.apply(context, configuration) + + expect(context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_PEER + end + + it "requires a peer certificate" do + configuration = IO::Endpoint::TLS::Configuration.new(verification: :required) + required_verification = ::OpenSSL::SSL::VERIFY_PEER | ::OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + + subject.apply(context, configuration) + + expect(context.verify_mode).to be == required_verification + end + end end From b2b920fcaeb71b15cfd565b9b5a934cbe68dfee3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:46:19 +1200 Subject: [PATCH 04/10] Verify certificate hostnames for TLS clients Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- context/getting-started.md | 2 +- guides/getting-started/readme.md | 2 +- lib/io/endpoint/ssl_endpoint.rb | 4 ++++ test/io/endpoint/ssl_endpoint.rb | 26 ++++++++++++++++++++++++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 34114a3..6f303f8 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -141,4 +141,4 @@ The SSL endpoint converts the trust store into an OpenSSL certificate store and The OpenSSL conversion is also available directly using `IO::Endpoint::TLS::OpenSSL.build_certificate_store(trust_store)`. -For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. +For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. On client connections with a hostname, peer verification also checks that the certificate identifies that hostname. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 34114a3..6f303f8 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -141,4 +141,4 @@ The SSL endpoint converts the trust store into an OpenSSL certificate store and The OpenSSL conversion is also available directly using `IO::Endpoint::TLS::OpenSSL.build_certificate_store(trust_store)`. -For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. +For a server which requires clients to provide a trusted certificate, set `verification: :required`. Use `:peer` to verify a certificate when the peer provides one, and `:none` to explicitly disable peer verification. When a trust store is supplied and no policy is specified, peer verification is enabled by default. On client connections with a hostname, peer verification also checks that the certificate identifies that hostname. diff --git a/lib/io/endpoint/ssl_endpoint.rb b/lib/io/endpoint/ssl_endpoint.rb index 516276a..89f6b7c 100644 --- a/lib/io/endpoint/ssl_endpoint.rb +++ b/lib/io/endpoint/ssl_endpoint.rb @@ -159,6 +159,10 @@ def connect(&block) begin socket.connect + + if hostname && [:peer, :required].include?(tls_configuration&.verification) + socket.post_connection_check(hostname) + end rescue socket.close raise diff --git a/test/io/endpoint/ssl_endpoint.rb b/test/io/endpoint/ssl_endpoint.rb index 6485c39..8c5975d 100644 --- a/test/io/endpoint/ssl_endpoint.rb +++ b/test/io/endpoint/ssl_endpoint.rb @@ -51,6 +51,10 @@ def client_endpoint(address) with "mutual TLS configuration" do include Sus::Fixtures::OpenSSL::ValidCertificateContext + def certificate_name + return ::OpenSSL::X509::Name.parse("/O=Test/CN=localhost") + end + let(:trust_store) do IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) end @@ -77,9 +81,9 @@ def client_endpoint(address) let(:endpoint) {IO::Endpoint.tcp("localhost", 0)} let(:server_endpoint) {subject.new(endpoint, tls_configuration: server_tls_configuration)} - def client_endpoint(address) + def client_endpoint(address, hostname: "localhost") endpoint = IO::Endpoint::AddressEndpoint.new(address) - return subject.new(endpoint, tls_configuration: client_tls_configuration, hostname: "localhost") + return subject.new(endpoint, tls_configuration: client_tls_configuration, hostname: hostname) end it "authenticates both peers" do @@ -100,6 +104,24 @@ def client_endpoint(address) ensure bound&.close end + + it "rejects a trusted certificate for a different hostname" do + bound = server_endpoint.bound + + bound.bind do |server| + peer, address = server.accept + peer.accept + peer.close + end + + bound.sockets.each do |server| + expect do + client_endpoint(server.local_address, hostname: "example.com").connect + end.to raise_exception(::OpenSSL::SSL::SSLError, message: be =~ /hostname/) + end + ensure + bound&.close + end end with "invalid certificates" do From 413f70a2690b7c44bef2047b495d2316b5534b17 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:51:14 +1200 Subject: [PATCH 05/10] Configure hostname verification through OpenSSL Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- lib/io/endpoint/ssl_endpoint.rb | 6 +----- lib/io/endpoint/tls/configuration.rb | 6 ++++++ lib/io/endpoint/tls/openssl.rb | 8 +++++++- test/io/endpoint/ssl_endpoint.rb | 10 ++++++++-- test/io/endpoint/tls/configuration.rb | 9 +++++++++ test/io/endpoint/tls/openssl.rb | 11 +++++++++++ 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/lib/io/endpoint/ssl_endpoint.rb b/lib/io/endpoint/ssl_endpoint.rb index 89f6b7c..ff039a8 100644 --- a/lib/io/endpoint/ssl_endpoint.rb +++ b/lib/io/endpoint/ssl_endpoint.rb @@ -96,7 +96,7 @@ def build_context(context = ::OpenSSL::SSL::SSLContext.new) end if tls_configuration = self.tls_configuration - TLS::OpenSSL.apply(context, tls_configuration) + TLS::OpenSSL.apply(context, tls_configuration, hostname: self.hostname) end # context.setup @@ -159,10 +159,6 @@ def connect(&block) begin socket.connect - - if hostname && [:peer, :required].include?(tls_configuration&.verification) - socket.post_connection_check(hostname) - end rescue socket.close raise diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb index ae0345c..8850f55 100644 --- a/lib/io/endpoint/tls/configuration.rb +++ b/lib/io/endpoint/tls/configuration.rb @@ -44,6 +44,12 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif # @attribute [Symbol | Nil] The peer verification policy. attr :verification + # Whether peer certificates should be verified. + # @returns [Boolean] Whether peer verification is enabled. + def verify_peer? + return @verification == :peer || @verification == :required + end + # Get a representation of the configuration without exposing certificate or private key material. # @returns [String] A redacted representation of the configuration. def inspect diff --git a/lib/io/endpoint/tls/openssl.rb b/lib/io/endpoint/tls/openssl.rb index 425fe59..b99fc66 100644 --- a/lib/io/endpoint/tls/openssl.rb +++ b/lib/io/endpoint/tls/openssl.rb @@ -30,8 +30,9 @@ def self.build_certificate_store(trust_store) # Apply transport-neutral TLS configuration to an OpenSSL context. # @parameter context [OpenSSL::SSL::SSLContext] The OpenSSL context to configure. # @parameter configuration [Configuration] The transport-neutral TLS configuration. + # @parameter hostname [String | Nil] The hostname to verify for client connections. # @returns [OpenSSL::SSL::SSLContext] The configured OpenSSL context. - def self.apply(context, configuration) + def self.apply(context, configuration, hostname: nil) if trust_store = configuration.trust_store context.cert_store = build_certificate_store(trust_store) end @@ -46,12 +47,17 @@ def self.apply(context, configuration) case configuration.verification when :none context.verify_mode = ::OpenSSL::SSL::VERIFY_NONE + context.verify_hostname = false when :peer context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER when :required context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER | ::OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT end + if hostname && configuration.verify_peer? + context.verify_hostname = true + end + return context end end diff --git a/test/io/endpoint/ssl_endpoint.rb b/test/io/endpoint/ssl_endpoint.rb index 8c5975d..2f5a315 100644 --- a/test/io/endpoint/ssl_endpoint.rb +++ b/test/io/endpoint/ssl_endpoint.rb @@ -110,8 +110,14 @@ def client_endpoint(address, hostname: "localhost") bound.bind do |server| peer, address = server.accept - peer.accept - peer.close + + begin + peer.accept + rescue ::OpenSSL::SSL::SSLError + # The client rejects the server certificate during the handshake. + ensure + peer.close + end end bound.sockets.each do |server| diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 4c2bc7e..b5022ef 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -29,6 +29,7 @@ it "verifies peers by default when a trust store is provided" do expect(configuration.verification).to be == :peer + expect(configuration).to be(:verify_peer?) end it "does not expose certificate or private key material when inspected" do @@ -68,4 +69,12 @@ end.to raise_exception(ArgumentError, message: be =~ /Unsupported verification policy/) end end + + with "verification disabled" do + it "does not verify peers" do + configuration = subject.new(verification: :none) + + expect(configuration).not.to be(:verify_peer?) + end + end end diff --git a/test/io/endpoint/tls/openssl.rb b/test/io/endpoint/tls/openssl.rb index 580fb8c..8da4f12 100644 --- a/test/io/endpoint/tls/openssl.rb +++ b/test/io/endpoint/tls/openssl.rb @@ -60,11 +60,13 @@ it "disables peer verification when requested" do context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER + context.verify_hostname = true configuration = IO::Endpoint::TLS::Configuration.new(verification: :none) subject.apply(context, configuration) expect(context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_NONE + expect(context.verify_hostname).to be_falsey end it "enables peer verification" do @@ -73,6 +75,15 @@ subject.apply(context, configuration) expect(context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_PEER + expect(context.verify_hostname).to be_falsey + end + + it "enables hostname verification for verified clients" do + configuration = IO::Endpoint::TLS::Configuration.new(verification: :peer) + + subject.apply(context, configuration, hostname: "example.com") + + expect(context.verify_hostname).to be_truthy end it "requires a peer certificate" do From f3c9807e8f7e54996fcfebd60736aec4b8eb37a8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 12:53:49 +1200 Subject: [PATCH 06/10] Document transport-neutral TLS support Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- readme.md | 6 ++---- releases.md | 2 ++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/readme.md b/readme.md index f7c23b7..c035566 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,8 @@ Please see the [project releases](https://socketry.github.io/io-endpointreleases ### Unreleased - The `openssl` gem 3.3.0 or newer is now required. + - Added transport-neutral TLS trust stores and configuration, suitable for future QUIC integration. + - Added OpenSSL conversion and SSL endpoint integration, including hostname verification and mutual TLS. ### v0.17.2 @@ -58,10 +60,6 @@ Please see the [project releases](https://socketry.github.io/io-endpointreleases - Fixed state leak between iterations of the accept loop. -### v0.13.0 - - - Propagate options assigned to composite endpoint to nested endpoints. - ## See Also - [async-io](https://github.com/socketry/async-io) — Where this implementation originally came from. diff --git a/releases.md b/releases.md index 8232d66..2fb6194 100644 --- a/releases.md +++ b/releases.md @@ -3,6 +3,8 @@ ## Unreleased - The `openssl` gem 3.3.0 or newer is now required. + - Added transport-neutral TLS trust stores and configuration, suitable for future QUIC integration. + - Added OpenSSL conversion and SSL endpoint integration, including hostname verification and mutual TLS. ## v0.17.2 From 36ce85f2b25388992ff78bcff4f58440be602aa1 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 13:29:22 +1200 Subject: [PATCH 07/10] Represent certificate chains as ordered arrays Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- context/getting-started.md | 3 +++ guides/getting-started/readme.md | 3 +++ lib/io/endpoint.rb | 1 + lib/io/endpoint/tls/certificates.rb | 35 +++++++++++++++++++++++++++ lib/io/endpoint/tls/configuration.rb | 14 +++++++++-- lib/io/endpoint/tls/openssl.rb | 5 +++- lib/io/endpoint/tls/trust_store.rb | 20 +++------------ readme.md | 2 +- releases.md | 2 +- test/io/endpoint/ssl_endpoint.rb | 2 +- test/io/endpoint/tls/certificates.rb | 31 ++++++++++++++++++++++++ test/io/endpoint/tls/configuration.rb | 18 ++++++++++++-- test/io/endpoint/tls/openssl.rb | 2 +- 13 files changed, 113 insertions(+), 25 deletions(-) create mode 100644 lib/io/endpoint/tls/certificates.rb create mode 100644 test/io/endpoint/tls/certificates.rb diff --git a/context/getting-started.md b/context/getting-started.md index 6f303f8..3df2be9 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -118,6 +118,7 @@ Use {ruby IO::Endpoint::TLS::Configuration} to provide certificate and private k ```ruby trust_store = IO::Endpoint::TLS::TrustStore.parse(root_certificates) +certificate_chain = IO::Endpoint::TLS::Certificates.parse(certificate_chain_bundle) tls_configuration = IO::Endpoint::TLS::Configuration.new( trust_store: trust_store, @@ -135,6 +136,8 @@ endpoint = IO::Endpoint.ssl( Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. +The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate first. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. + Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. The SSL endpoint converts the trust store into an OpenSSL certificate store and the complete configuration into an OpenSSL context. Other endpoint implementations can consume the same trust, identity, and verification configuration using their native TLS implementation. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 6f303f8..3df2be9 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -118,6 +118,7 @@ Use {ruby IO::Endpoint::TLS::Configuration} to provide certificate and private k ```ruby trust_store = IO::Endpoint::TLS::TrustStore.parse(root_certificates) +certificate_chain = IO::Endpoint::TLS::Certificates.parse(certificate_chain_bundle) tls_configuration = IO::Endpoint::TLS::Configuration.new( trust_store: trust_store, @@ -135,6 +136,8 @@ endpoint = IO::Endpoint.ssl( Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. +The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate first. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. + Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. The SSL endpoint converts the trust store into an OpenSSL certificate store and the complete configuration into an OpenSSL context. Other endpoint implementations can consume the same trust, identity, and verification configuration using their native TLS implementation. diff --git a/lib/io/endpoint.rb b/lib/io/endpoint.rb index 0e05c8a..fbeb734 100644 --- a/lib/io/endpoint.rb +++ b/lib/io/endpoint.rb @@ -6,6 +6,7 @@ require_relative "endpoint/version" require_relative "endpoint/generic" require_relative "endpoint/shared_endpoint" +require_relative "endpoint/tls/certificates" require_relative "endpoint/tls/trust_store" require_relative "endpoint/tls/configuration" diff --git a/lib/io/endpoint/tls/certificates.rb b/lib/io/endpoint/tls/certificates.rb new file mode 100644 index 0000000..5851d4a --- /dev/null +++ b/lib/io/endpoint/tls/certificates.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +module IO::Endpoint + # @namespace + module TLS + # Utilities for parsing PEM-encoded certificates. + module Certificates + # Matches individual certificates in a PEM bundle. + CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m + private_constant :CERTIFICATE_PATTERN + + # Parse a PEM-encoded certificate bundle into individual certificates. + # @parameter certificate_bundle [String] One or more certificates encoded as PEM. + # @returns [Array(String)] The individual PEM-encoded certificates in their original order. + # @raises [ArgumentError] If the bundle does not contain any certificates. + # @raises [TypeError] If the bundle is not a string. + def self.parse(certificate_bundle) + unless certificate_bundle.is_a?(String) + raise TypeError, "The certificate bundle must be provided as a string!" + end + + certificates = certificate_bundle.scan(CERTIFICATE_PATTERN) + + unless certificates.any? + raise ArgumentError, "The certificate bundle does not contain any certificates!" + end + + return certificates + end + end + end +end diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb index 8850f55..986414c 100644 --- a/lib/io/endpoint/tls/configuration.rb +++ b/lib/io/endpoint/tls/configuration.rb @@ -12,7 +12,7 @@ module TLS class Configuration # Initialize a TLS configuration from PEM-encoded certificate and private key material. # @parameter trust_store [TrustStore | Nil] The trusted certificate sources. - # @parameter certificate_chain [String | Nil] The local certificate chain encoded as PEM, with the leaf certificate first. + # @parameter certificate_chain [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate first. # @parameter private_key [String | Nil] The private key encoded as PEM. # @parameter verification [Symbol | Nil] The peer verification policy: `:none`, `:peer`, or `:required`. When omitted, `:peer` is used if a trust store is provided. # @raises [ArgumentError] If the certificate chain and private key are not provided together, or the verification policy is invalid. @@ -21,6 +21,16 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif raise ArgumentError, "The certificate chain and private key must be provided together!" end + if certificate_chain + unless certificate_chain.is_a?(Array) && certificate_chain.all?{|certificate| certificate.is_a?(String)} + raise TypeError, "The certificate chain must be provided as an array of strings!" + end + + unless certificate_chain.any? + raise ArgumentError, "The certificate chain must contain at least one certificate!" + end + end + verification = :peer if verification.nil? && trust_store unless [nil, :none, :peer, :required].include?(verification) raise ArgumentError, "Unsupported verification policy: #{verification.inspect}!" @@ -35,7 +45,7 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif # @attribute [TrustStore | Nil] The trusted certificate sources. attr :trust_store - # @attribute [String | Nil] The local certificate chain encoded as PEM, with the leaf certificate first. + # @attribute [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate first. attr :certificate_chain # @attribute [String | Nil] The private key encoded as PEM. diff --git a/lib/io/endpoint/tls/openssl.rb b/lib/io/endpoint/tls/openssl.rb index b99fc66..ff02716 100644 --- a/lib/io/endpoint/tls/openssl.rb +++ b/lib/io/endpoint/tls/openssl.rb @@ -38,7 +38,10 @@ def self.apply(context, configuration, hostname: nil) end if certificate_chain = configuration.certificate_chain - certificates = ::OpenSSL::X509::Certificate.load(certificate_chain) + certificates = certificate_chain.map do |certificate| + ::OpenSSL::X509::Certificate.new(certificate) + end + context.cert = certificates.shift context.extra_chain_cert = certificates unless certificates.empty? context.key = ::OpenSSL::PKey.read(configuration.private_key) diff --git a/lib/io/endpoint/tls/trust_store.rb b/lib/io/endpoint/tls/trust_store.rb index 12ade08..ed657d9 100644 --- a/lib/io/endpoint/tls/trust_store.rb +++ b/lib/io/endpoint/tls/trust_store.rb @@ -3,15 +3,13 @@ # Released under the MIT License. # Copyright, 2026, by Samuel Williams. +require_relative "certificates" + module IO::Endpoint # @namespace module TLS # Represents transport-neutral trusted certificate sources. class TrustStore - # Matches individual certificates in a PEM bundle. - CERTIFICATE_PATTERN = /-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/m - private_constant :CERTIFICATE_PATTERN - # Load a PEM-encoded certificate bundle from the given path. # @parameter path [String | Interface(:to_path)] The path to the certificate bundle. # @parameter options [Hash] Options forwarded to {.parse}. @@ -27,17 +25,7 @@ def self.load(path, **options) # @raises [ArgumentError] If the bundle does not contain any certificates. # @raises [TypeError] If the bundle is not a string. def self.parse(certificate_bundle, system_certificates: false) - unless certificate_bundle.is_a?(String) - raise TypeError, "The certificate bundle must be provided as a string!" - end - - certificates = certificate_bundle.scan(CERTIFICATE_PATTERN) - - unless certificates.any? - raise ArgumentError, "The certificate bundle does not contain any certificates!" - end - - return self.new(certificates: certificates, system_certificates: system_certificates) + return self.new(certificates: Certificates.parse(certificate_bundle), system_certificates: system_certificates) end # Initialize a trust store from PEM-encoded trusted certificates. @@ -58,7 +46,7 @@ def initialize(certificates: [], system_certificates: false) @system_certificates = system_certificates end - # @attribute [Array(String)] The trusted certificate bundles encoded as PEM. + # @attribute [Array(String)] The individual trusted certificates encoded as PEM. attr :certificates # Whether system-provided trusted certificates should be included. diff --git a/readme.md b/readme.md index c035566..c8f01f7 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,7 @@ Please see the [project releases](https://socketry.github.io/io-endpointreleases ### Unreleased - The `openssl` gem 3.3.0 or newer is now required. - - Added transport-neutral TLS trust stores and configuration, suitable for future QUIC integration. + - Added transport-neutral TLS trust stores, certificate chains and configuration, suitable for future QUIC integration. - Added OpenSSL conversion and SSL endpoint integration, including hostname verification and mutual TLS. ### v0.17.2 diff --git a/releases.md b/releases.md index 2fb6194..deb095f 100644 --- a/releases.md +++ b/releases.md @@ -3,7 +3,7 @@ ## Unreleased - The `openssl` gem 3.3.0 or newer is now required. - - Added transport-neutral TLS trust stores and configuration, suitable for future QUIC integration. + - Added transport-neutral TLS trust stores, certificate chains and configuration, suitable for future QUIC integration. - Added OpenSSL conversion and SSL endpoint integration, including hostname verification and mutual TLS. ## v0.17.2 diff --git a/test/io/endpoint/ssl_endpoint.rb b/test/io/endpoint/ssl_endpoint.rb index 2f5a315..8528646 100644 --- a/test/io/endpoint/ssl_endpoint.rb +++ b/test/io/endpoint/ssl_endpoint.rb @@ -58,7 +58,7 @@ def certificate_name let(:trust_store) do IO::Endpoint::TLS::TrustStore.parse(certificate_authority_certificate.to_pem) end - let(:certificate_chain) {certificate.to_pem + certificate_authority_certificate.to_pem} + let(:certificate_chain) {[certificate.to_pem]} let(:private_key) {key.to_pem} let(:server_tls_configuration) do diff --git a/test/io/endpoint/tls/certificates.rb b/test/io/endpoint/tls/certificates.rb new file mode 100644 index 0000000..a5098f3 --- /dev/null +++ b/test/io/endpoint/tls/certificates.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/endpoint/tls/certificates" + +describe IO::Endpoint::TLS::Certificates do + let(:first_certificate) {"-----BEGIN CERTIFICATE-----\nfirst\n-----END CERTIFICATE-----"} + let(:second_certificate) {"-----BEGIN CERTIFICATE-----\nsecond\n-----END CERTIFICATE-----"} + + with ".parse" do + it "splits a certificate bundle while preserving order" do + certificates = subject.parse("#{first_certificate}\n#{second_certificate}\n") + + expect(certificates).to be == [first_certificate, second_certificate] + end + + it "rejects a bundle without certificates" do + expect do + subject.parse("not a certificate bundle") + end.to raise_exception(ArgumentError, message: be =~ /does not contain any certificates/) + end + + it "rejects a bundle which is not a string" do + expect do + subject.parse(Object.new) + end.to raise_exception(TypeError, message: be =~ /must be provided as a string/) + end + end +end diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index b5022ef..87e8fef 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -9,7 +9,7 @@ let(:certificate) {"trusted certificate"} let(:certificates) {[certificate]} let(:trust_store) {IO::Endpoint::TLS::TrustStore.new(certificates: certificates)} - let(:certificate_chain) {"certificate chain"} + let(:certificate_chain) {["certificate chain"]} let(:private_key) {"private key"} with "certificate material" do @@ -36,7 +36,7 @@ representation = configuration.inspect expect(representation).not.to be(:include?, certificate) - expect(representation).not.to be(:include?, certificate_chain) + expect(representation).not.to be(:include?, certificate_chain.first) expect(representation).not.to be(:include?, private_key) expect(representation).to be(:include?, "private_key") end @@ -56,6 +56,20 @@ end end + with "an unsupported certificate chain representation" do + it "rejects a concatenated string" do + expect do + subject.new(certificate_chain: "certificate chain", private_key: private_key) + end.to raise_exception(TypeError, message: be =~ /array of strings/) + end + + it "rejects an empty chain" do + expect do + subject.new(certificate_chain: [], private_key: private_key) + end.to raise_exception(ArgumentError, message: be =~ /at least one certificate/) + end + end + with "an unsupported verification policy" do it "rejects the policy" do expect do diff --git a/test/io/endpoint/tls/openssl.rb b/test/io/endpoint/tls/openssl.rb index 8da4f12..24bba7e 100644 --- a/test/io/endpoint/tls/openssl.rb +++ b/test/io/endpoint/tls/openssl.rb @@ -36,7 +36,7 @@ it "applies the trust store and local identity" do configuration = IO::Endpoint::TLS::Configuration.new( trust_store: trust_store, - certificate_chain: certificate.to_pem + certificate_authority_certificate.to_pem, + certificate_chain: [certificate.to_pem, certificate_authority_certificate.to_pem], private_key: key.to_pem, ) From a333681e1c75fbbe32681be5fe1c2c5c40c0a2ad Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 13:31:53 +1200 Subject: [PATCH 08/10] Preserve canonical certificate chain semantics Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- lib/io/endpoint/tls/openssl.rb | 6 ++---- test/io/endpoint/tls/openssl.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/lib/io/endpoint/tls/openssl.rb b/lib/io/endpoint/tls/openssl.rb index ff02716..8b319cb 100644 --- a/lib/io/endpoint/tls/openssl.rb +++ b/lib/io/endpoint/tls/openssl.rb @@ -20,9 +20,7 @@ def self.build_certificate_store(trust_store) store.set_default_paths if trust_store.system_certificates? trust_store.certificates.each do |certificate_pem| - ::OpenSSL::X509::Certificate.load(certificate_pem).each do |certificate| - store.add_cert(certificate) - end + store.add_cert(::OpenSSL::X509::Certificate.new(certificate_pem)) end end end @@ -43,7 +41,7 @@ def self.apply(context, configuration, hostname: nil) end context.cert = certificates.shift - context.extra_chain_cert = certificates unless certificates.empty? + context.extra_chain_cert = certificates context.key = ::OpenSSL::PKey.read(configuration.private_key) end diff --git a/test/io/endpoint/tls/openssl.rb b/test/io/endpoint/tls/openssl.rb index 24bba7e..af6a59f 100644 --- a/test/io/endpoint/tls/openssl.rb +++ b/test/io/endpoint/tls/openssl.rb @@ -49,6 +49,18 @@ expect(context.key.public_to_der).to be == key.public_to_der end + it "clears an existing extra certificate chain" do + context.extra_chain_cert = [certificate_authority_certificate] + configuration = IO::Endpoint::TLS::Configuration.new( + certificate_chain: [certificate.to_pem], + private_key: key.to_pem, + ) + + subject.apply(context, configuration) + + expect(context.extra_chain_cert).to be == [] + end + it "preserves the verification mode when no policy is specified" do context.verify_mode = ::OpenSSL::SSL::VERIFY_PEER configuration = IO::Endpoint::TLS::Configuration.new From 497d39f4f53a501f8dce51b9b66ea388815a3167 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 13:32:33 +1200 Subject: [PATCH 09/10] Clarify local TLS identity representation Signed-off-by: Samuel Williams Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- context/getting-started.md | 2 +- guides/getting-started/readme.md | 2 +- lib/io/endpoint/tls/configuration.rb | 17 +++++++++++------ test/io/endpoint/tls/configuration.rb | 8 ++++++++ 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/context/getting-started.md b/context/getting-started.md index 3df2be9..effa18b 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -136,7 +136,7 @@ endpoint = IO::Endpoint.ssl( Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. -The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate first. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. +The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate followed by any intermediate certificates. The root certificate is normally omitted. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 3df2be9..effa18b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -136,7 +136,7 @@ endpoint = IO::Endpoint.ssl( Use `TrustStore.new(certificates: certificates)` when certificates are already represented as an array of individual PEM strings. Use `TrustStore.parse(certificate_bundle)` to split a concatenated PEM bundle into that canonical representation, or `TrustStore.load("/path/to/certificates.pem")` to load and parse a bundle from a file. Options such as `system_certificates: true` are forwarded from `load` to `parse`. -The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate first. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. +The local certificate chain is an ordered array of individual PEM strings, with the leaf certificate followed by any intermediate certificates. The root certificate is normally omitted. Use `Certificates.parse(certificate_bundle)` to split a concatenated bundle while preserving that order. Set `system_certificates: true` to include system-provided trusted certificates. System and custom certificates can be combined in the same trust store. diff --git a/lib/io/endpoint/tls/configuration.rb b/lib/io/endpoint/tls/configuration.rb index 986414c..d7b13ba 100644 --- a/lib/io/endpoint/tls/configuration.rb +++ b/lib/io/endpoint/tls/configuration.rb @@ -12,15 +12,12 @@ module TLS class Configuration # Initialize a TLS configuration from PEM-encoded certificate and private key material. # @parameter trust_store [TrustStore | Nil] The trusted certificate sources. - # @parameter certificate_chain [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate first. + # @parameter certificate_chain [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate followed by any intermediates. # @parameter private_key [String | Nil] The private key encoded as PEM. # @parameter verification [Symbol | Nil] The peer verification policy: `:none`, `:peer`, or `:required`. When omitted, `:peer` is used if a trust store is provided. # @raises [ArgumentError] If the certificate chain and private key are not provided together, or the verification policy is invalid. + # @raises [TypeError] If the certificate chain or private key uses an unsupported representation. def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verification: nil) - if certificate_chain.nil? != private_key.nil? - raise ArgumentError, "The certificate chain and private key must be provided together!" - end - if certificate_chain unless certificate_chain.is_a?(Array) && certificate_chain.all?{|certificate| certificate.is_a?(String)} raise TypeError, "The certificate chain must be provided as an array of strings!" @@ -31,6 +28,14 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif end end + unless private_key.nil? || private_key.is_a?(String) + raise TypeError, "The private key must be provided as a string!" + end + + if certificate_chain.nil? != private_key.nil? + raise ArgumentError, "The certificate chain and private key must be provided together!" + end + verification = :peer if verification.nil? && trust_store unless [nil, :none, :peer, :required].include?(verification) raise ArgumentError, "Unsupported verification policy: #{verification.inspect}!" @@ -45,7 +50,7 @@ def initialize(trust_store: nil, certificate_chain: nil, private_key: nil, verif # @attribute [TrustStore | Nil] The trusted certificate sources. attr :trust_store - # @attribute [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate first. + # @attribute [Array(String) | Nil] The ordered local certificate chain encoded as individual PEM strings, with the leaf certificate followed by any intermediates. attr :certificate_chain # @attribute [String | Nil] The private key encoded as PEM. diff --git a/test/io/endpoint/tls/configuration.rb b/test/io/endpoint/tls/configuration.rb index 87e8fef..0ba4a8f 100644 --- a/test/io/endpoint/tls/configuration.rb +++ b/test/io/endpoint/tls/configuration.rb @@ -70,6 +70,14 @@ end end + with "an unsupported private key representation" do + it "rejects a value which is not a string" do + expect do + subject.new(certificate_chain: certificate_chain, private_key: Object.new) + end.to raise_exception(TypeError, message: be =~ /private key must be provided as a string/) + end + end + with "an unsupported verification policy" do it "rejects the policy" do expect do From c331a761e4ff1e1bd340f9bf572c0b7d6d2ce1dd Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 19 Aug 2026 13:49:49 +1200 Subject: [PATCH 10/10] Test all SSL and TLS code paths Assisted-By: devx/3ed43c18-3c9a-4ad9-a6f5-6668caa29658 --- test/io/endpoint/ssl_endpoint.rb | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/io/endpoint/ssl_endpoint.rb b/test/io/endpoint/ssl_endpoint.rb index 8528646..e9e09aa 100644 --- a/test/io/endpoint/ssl_endpoint.rb +++ b/test/io/endpoint/ssl_endpoint.rb @@ -15,6 +15,13 @@ let(:endpoint) {IO::Endpoint.tcp("localhost", 0)} let(:server_endpoint) {subject.new(endpoint, ssl_context: server_context)} + it "can bind with a block" do + server_endpoint.bind do |server| + expect(server).to be_a(::OpenSSL::SSL::SSLServer) + expect(server.start_immediately).to be_falsey + end + end + def client_endpoint(address) endpoint = IO::Endpoint::AddressEndpoint.new(address) return subject.new(endpoint, ssl_context: client_context) @@ -173,6 +180,35 @@ def client_endpoint(address) with "a simple SSL endpoint" do let(:endpoint) {subject.new(IO::Endpoint.tcp("localhost", 0))} + with "#address" do + it "delegates to the underlying endpoint" do + address = Addrinfo.tcp("localhost", 0) + endpoint = subject.new(IO::Endpoint::AddressEndpoint.new(address)) + + expect(endpoint.address).to be_equal(address) + end + end + + with "#build_context" do + it "applies explicit SSL parameters" do + endpoint = subject.new( + IO::Endpoint.tcp("localhost", 0), + ssl_params: {verify_mode: ::OpenSSL::SSL::VERIFY_PEER}, + ) + + expect(endpoint.context.verify_mode).to be == ::OpenSSL::SSL::VERIFY_PEER + end + end + + with "#each" do + it "wraps each underlying endpoint" do + enumerator = endpoint.each + + expect(enumerator).to be_a(Enumerator) + expect(enumerator.to_a).to have_value(be_a(subject)) + end + end + with "#to_s" do it "can generate a string representation" do expect(endpoint.to_s).to be =~ /ssl:/ @@ -185,4 +221,13 @@ def client_endpoint(address) end end end + + with ".ssl" do + it "constructs an SSL endpoint" do + endpoint = IO::Endpoint.ssl("localhost", 443, hostname: "example.com") + + expect(endpoint).to be_a(subject) + expect(endpoint.hostname).to be == "example.com" + end + end end