Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions context/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions guides/getting-started/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions lib/io/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions lib/io/endpoint/ssl_endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

require_relative "host_endpoint"
require_relative "generic"
require_relative "tls/openssl"

require "openssl"

Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
35 changes: 35 additions & 0 deletions lib/io/endpoint/tls/certificates.rb
Original file line number Diff line number Diff line change
@@ -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
82 changes: 82 additions & 0 deletions lib/io/endpoint/tls/configuration.rb
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions lib/io/endpoint/tls/openssl.rb
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions lib/io/endpoint/tls/trust_store.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading