diff --git a/context/getting-started.md b/context/getting-started.md index 1d967c3..effa18b 100644 --- a/context/getting-started.md +++ b/context/getting-started.md @@ -111,3 +111,37 @@ 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) +certificate_chain = IO::Endpoint::TLS::Certificates.parse(certificate_chain_bundle) + +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`. + +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. + +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. 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 1d967c3..effa18b 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -111,3 +111,37 @@ 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) +certificate_chain = IO::Endpoint::TLS::Certificates.parse(certificate_chain_bundle) + +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`. + +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. + +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. On client connections with a hostname, peer verification also checks that the certificate identifies that hostname. diff --git a/lib/io/endpoint.rb b/lib/io/endpoint.rb index a0afa15..fbeb734 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/certificates" +require_relative "endpoint/tls/trust_store" +require_relative "endpoint/tls/configuration" # 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..ff039a8 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, hostname: self.hostname) + 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/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 new file mode 100644 index 0000000..d7b13ba --- /dev/null +++ b/lib/io/endpoint/tls/configuration.rb @@ -0,0 +1,82 @@ +# 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 [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 + 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 + + 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}!" + 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 [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. + attr :private_key + + # @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 + 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..8b319cb --- /dev/null +++ b/lib/io/endpoint/tls/openssl.rb @@ -0,0 +1,66 @@ +# 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_pem| + store.add_cert(::OpenSSL::X509::Certificate.new(certificate_pem)) + 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. + # @parameter hostname [String | Nil] The hostname to verify for client connections. + # @returns [OpenSSL::SSL::SSLContext] The configured OpenSSL context. + def self.apply(context, configuration, hostname: nil) + if trust_store = configuration.trust_store + context.cert_store = build_certificate_store(trust_store) + end + + if certificate_chain = configuration.certificate_chain + certificates = certificate_chain.map do |certificate| + ::OpenSSL::X509::Certificate.new(certificate) + end + + context.cert = certificates.shift + context.extra_chain_cert = certificates + context.key = ::OpenSSL::PKey.read(configuration.private_key) + end + + 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 + 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..ed657d9 --- /dev/null +++ b/lib/io/endpoint/tls/trust_store.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# 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 + # 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) + return self.new(certificates: Certificates.parse(certificate_bundle), 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 individual trusted certificates 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/readme.md b/readme.md index f7c23b7..c8f01f7 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, 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 @@ -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..deb095f 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, 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 a556449..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) @@ -48,6 +55,88 @@ def client_endpoint(address) end end + 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 + let(:certificate_chain) {[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, hostname: "localhost") + endpoint = IO::Endpoint::AddressEndpoint.new(address) + return subject.new(endpoint, tls_configuration: client_tls_configuration, hostname: hostname) + 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 + + it "rejects a trusted certificate for a different hostname" do + bound = server_endpoint.bound + + bound.bind do |server| + peer, address = server.accept + + 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| + 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 include Sus::Fixtures::OpenSSL::InvalidCertificateContext include Sus::Fixtures::OpenSSL::VerifiedCertificateContext @@ -91,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:/ @@ -103,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 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 new file mode 100644 index 0000000..0ba4a8f --- /dev/null +++ b/test/io/endpoint/tls/configuration.rb @@ -0,0 +1,102 @@ +# 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 + expect(configuration).to be(:verify_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.first) + 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 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 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 + 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 + + 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 new file mode 100644 index 0000000..af6a59f --- /dev/null +++ b/test/io/endpoint/tls/openssl.rb @@ -0,0 +1,110 @@ +# 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 + + 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 + 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 + + 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 "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 + + 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 + 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 + configuration = IO::Endpoint::TLS::Configuration.new(verification: :peer) + + 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 + 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 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