rubygems-pq-tls-policy

RubyGems plugin that checks negotiated TLS properties for RubyGems gem-server HTTPS connections when explicitly enabled.

This repository is an experimental starting point for testing post-quantum TLS policy enforcement around RubyGems operations.

Typical uses

Observe negotiated TLS groups:

RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_TRACE=1 \
  gem install rake

Require PQ TLS key exchange:

RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_POLICY=pq_required \
RUBYGEMS_GEM_SERVER_TLS_ALLOWED_GROUPS=X25519MLKEM768 \
  gem install rake

Observe certificate signatures:

RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_POLICY=pq_observe \
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_SCOPE=leaf \
  gem install rake

Require ML-DSA certificate signatures:

RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_POLICY=pq_required \
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_SCOPE=chain_all \
  gem install rake

Scope

When enabled, this plugin extends the Gem::Net::HTTP instances created by RubyGems' HTTPS connection pool and checks each connection after Net::HTTP#connect completes.

The intended scope is RubyGems HTTPS communication with configured gem servers and gem push hosts, such as https://rubygems.org or a private gem server. The hook is installed on RubyGems' HTTPS pool and on the individual RubyGems HTTP connection instances it creates; it is not installed globally on OpenSSL::SSL::SSLSocket or Net::HTTP.

This is not a sandbox. It does not restrict network connections made by:

  • gemspec evaluation
  • native extension build scripts
  • installed gems
  • RubyGems or Bundler plugins
  • git, ssh, curl, make, compiler toolchains, or other subprocesses
  • non-Ruby OpenSSL implementations
  • JRuby Java TLS internals, unless they expose the same Ruby OpenSSL behavior

For full egress control, use OS/container-level network policy.

Configuration

The plugin is disabled by default.

RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_POLICY=pq_required \
  gem install rake

Supported environment variables:

Variable Example Meaning
RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_POLICY pq_required Enables policy enforcement. default, off, disabled, or unset disables it.
RUBYGEMS_GEM_SERVER_TLS_ALLOWED_GROUPS X25519MLKEM768 Colon- or comma-separated list of allowed negotiated TLS groups.
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_POLICY pq_observe Enables certificate signature observation or enforcement. default, off, disabled, or unset disables it. pq_observe only traces; pq_required rejects non-compliant chains.
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_SCOPE leaf Certificate signature check scope: leaf, chain_any, or chain_all. Defaults to leaf.
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_TRACE 1 Prints certificate signature policy observations. pq_observe also prints these observations.
RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_TRACE 1 Prints observed TLS version, cipher, and negotiated group.

Default allowed group:

X25519MLKEM768

Default allowed certificate signature algorithms:

ML-DSA-44
ML-DSA-65
ML-DSA-87

The ML-DSA X.509/PKIX OIDs are also recognized:

2.16.840.1.101.3.4.3.17  # ML-DSA-44
2.16.840.1.101.3.4.3.18  # ML-DSA-65
2.16.840.1.101.3.4.3.19  # ML-DSA-87

Advanced option:

Variable Example Meaning
RUBYGEMS_GEM_SERVER_TLS_ALLOWED_CERT_SIGNATURE_ALGORITHMS ML-DSA-44:2.16.840.1.101.3.4.3.18 Colon- or comma-separated list of allowed certificate signature algorithm names or OIDs. This is intended for experiments with future algorithms, hybrid/composite identifiers, or vendor OIDs.

Example

gem install rubygems-pq-tls-policy

RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_POLICY=pq_required \
RUBYGEMS_GEM_SERVER_TLS_ALLOWED_GROUPS=X25519MLKEM768 \
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_POLICY=pq_observe \
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_SCOPE=leaf \
RUBYGEMS_GEM_SERVER_TLS_CERT_SIGNATURE_TRACE=1 \
RUBYGEMS_GEM_SERVER_TLS_KEY_EXCHANGE_TRACE=1 \
  gem install rake

A compliant connection prints trace output similar to:

[rubygems:tls] host=rubygems.org version=TLSv1.3 cipher=TLS_AES_256_GCM_SHA384 group="X25519MLKEM768"
[rubygems:tls] host=rubygems.org cert_signature_scope=leaf cert_signature_algorithms=["ML-DSA-44"] cert_pq=true

A non-compliant connection raises Gem::PqTlsPolicy::Violation before the RubyGems HTTP request proceeds.

The certificate signature policy is independent from the TLS key exchange policy. TLS key exchange checks inspect the negotiated TLS group, while certificate signature checks inspect the X.509 chain returned by SSLSocket#peer_cert_chain after the handshake. peer_cert_chain does not include the trust anchor/root certificate.

If the policy is enabled on an unsupported runtime, the RubyGems plugin entrypoint exits the gem command before the requested operation runs. This is intentional because RubyGems treats ordinary plugin load exceptions as warnings and otherwise continues.

Development

Install dependencies:

bundle install

Run unit tests:

bundle exec rake test

or:

script/test

Run a local diagnostic:

script/diagnose-tls

The diagnostic prints whether the current Ruby/OpenSSL exposes the APIs used by this plugin, especially:

  • OpenSSL::SSL::SSLSocket#group
  • Gem::Request::HTTPSPool#setup_connection
  • OpenSSL::SSL::SSLContext#groups=

OpenSSL::SSL::SSLContext#groups= is used only by this plugin's development test server to force specific TLS groups during local real-TLS tests.

Local real-TLS integration test

If your local Ruby is linked against an OpenSSL version with the required TLS group support, run:

script/integration

The integration script:

  1. builds a fixture gem,
  2. creates a static RubyGems repository,
  3. generates a localhost certificate,
  4. starts a localhost HTTPS gem server with X25519MLKEM768,
  5. runs gem fetch, gem install, bundle install, and gem push --host,
  6. verifies certificate signature pq_observe and pq_required behavior with classic and ML-DSA self-signed certificates,
  7. restarts the server with X25519, and
  8. verifies that non-PQ TLS group usage is rejected.

It also builds and installs this plugin into a temporary GEM_HOME, clears RUBYOPT, and verifies RubyGems plugin auto-loading from the installed gem.

The script loads the plugin from the checkout using RUBYOPT=-Ilib -rrubygems_plugin, so you can test changes before packaging or installing the gem.

Docker real-TLS integration test

If your local Ruby/OpenSSL is not suitable, use Docker:

script/integration-docker

This runs script/integration in the official ruby:4.0.5-trixie image, which currently provides Ruby 4.0.5 linked against OpenSSL 3.5. It also verifies that an installed plugin auto-load on ruby:4.0.5-bookworm fails closed before the requested gem operation runs.

To use another image:

RUBY_DOCKER_IMAGE=ruby:4.0.5-trixie script/integration-docker

To run only the certificate signature policy matrix used by GitHub Actions:

MATRIX=gha script/cert-signature-integration-docker

To run one certificate signature case:

CERT_SIG_CASE=pq-leaf-classic-chain \
CERT_SIG_POLICY=pq_required \
CERT_SIG_SCOPE=leaf \
CERT_SIG_EXPECT=pass \
  script/cert-signature-integration-docker

For an interactive shell:

script/shell-docker

Docker runtime condition test

To verify the plugin's runtime checks against both supported and unsupported Ruby/OpenSSL combinations:

script/runtime-check-docker

By default this expects ruby:4.0.5-trixie to pass and ruby:4.0.5-bookworm to fail before installation with Gem::PqTlsPolicy::UnsupportedRuntime.

To use different images:

SUPPORTED_RUBY_DOCKER_IMAGE=ruby:4.0.5-trixie \
UNSUPPORTED_RUBY_DOCKER_IMAGE=ruby:4.0.5-bookworm \
  script/runtime-check-docker

Docker compatibility probes

To run source-checkout probes on JRuby and TruffleRuby:

script/compatibility-docker

These probes load the plugin code directly from the checkout, print diagnostics, and verify that enabling the policy either installs cleanly or fails with Gem::PqTlsPolicy::UnsupportedRuntime.

To use different images:

JRUBY_DOCKER_IMAGE=jruby:latest \
TRUFFLERUBY_DOCKER_IMAGE=ghcr.io/flavorjones/truffleruby:latest \
  script/compatibility-docker

GitHub Actions

This repository includes three workflows:

Workflow Purpose
CI Unit tests and gem build on MRI Ruby.
Compatibility Docker probes on JRuby and TruffleRuby.
PQ TLS Integration Runs unit tests, localhost real-TLS command tests, certificate signature policy matrix cases, and unsupported-runtime fail-closed checks.

Compatibility results should be treated as observed behavior, not a compatibility guarantee. Docker latest tags are mutable, so JRuby and TruffleRuby rows record the runtime versions observed by the compatibility probe.

Observed compatibility:

Runtime Policy enablement SSLSocket#group Real TLS integration Notes
ruby:4.0.5-trixie (MRI Ruby 4.0.5 + OpenSSL 3.5) Default Docker integration/runtime-check image and PQ TLS Integration container.
ruby:4.0.5-bookworm N/A Expected to fail with Gem::PqTlsPolicy::UnsupportedRuntime.
JRuby 10.1.0.0 (Ruby 4.0.0), JRuby-OpenSSL 0.15.6 N/A Observed by source-checkout compatibility probe on 2026-05-22.
TruffleRuby 24.2.2 (Ruby 3.3.7), OpenSSL 3.5.1 N/A Observed by source-checkout compatibility probe on 2026-05-22; below the gemspec Ruby requirement.

Command coverage

The real-TLS integration currently exercises:

Command Path
gem fetch download/read
gem install download/read
bundle install Bundler resolution/install
gem push --host upload/write

The gem push test uses a local fake RubyGems-compatible HTTPS endpoint. It does not publish anything to RubyGems.org.

Security model

This plugin checks the negotiated TLS group after TLS handshake and hostname verification, but before RubyGems continues with the HTTPS request. When certificate signature policy is enabled, it also checks the peer certificate chain after TLS handshake and hostname verification.

It does not prevent a non-PQ TLS handshake from happening. It rejects the connection after observing the negotiated group. Likewise, certificate signature enforcement rejects the connection only after the server certificate chain has already been received and verified by the TLS stack.

For a stronger design, combine this plugin with client-side TLS group configuration, an internal gem mirror, or container/network-level egress policy.

Packaging

Build the gem:

gem build rubygems-pq-tls-policy.gemspec

Install locally:

gem install ./rubygems-pq-tls-policy-1.2.0.gem

License

MIT