Skip to content
Open
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
2 changes: 1 addition & 1 deletion lib/bundler/fetcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ def connection
if ssl_client_cert
pem = File.read(ssl_client_cert)
con.cert = OpenSSL::X509::Certificate.new(pem)
con.key = OpenSSL::PKey::RSA.new(pem)
con.key = OpenSSL::PKey.read(pem)
end

con.read_timeout = Fetcher.api_timeout
Expand Down
80 changes: 80 additions & 0 deletions spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# frozen_string_literal: true

require "bundler/fetcher"
require Spec::Path.rubygems_test_dir.join("local_ssl_server_utilities")

RSpec.describe "Bundler::Fetcher local SSL server", if: Gem::HAVE_OPENSSL do
include Gem::LocalSSLServerUtilities

before do
initialize_ssl_server
end

after do
stop_ssl_server
end

describe "#connection" do
context "non-PQC" do
it "connects" do
ssl_server = start_ssl_server
allow(Bundler.settings).to receive(:[]).and_call_original
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "ca_cert.pem"))
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end

it "connects with client cert auth" do
ssl_server = start_ssl_server(
verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
)
allow(Bundler.settings).to receive(:[]).and_call_original
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "ca_cert.pem"))
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(File.join(certs_dir, "client.pem"))
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
end

context "PQC" do
before do
skip_unless_support_pqc
end

it "connects" do
ssl_server = start_ssl_server(mode: :pqc)
allow(Bundler.settings).to receive(:[]).and_call_original
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "mldsa65_ca_cert.pem"))
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end

it "connects with client cert auth" do
ssl_server = start_ssl_server(
mode: :pqc,
verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
)
allow(Bundler.settings).to receive(:[]).and_call_original
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "mldsa65_ca_cert.pem"))
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(File.join(certs_dir, "mldsa65_client.pem"))
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
end
end

def fetch_path(uri)
uri = Gem::URI(uri)
remote = double("remote", uri: uri, original_uri: nil)
fetcher = Bundler::Fetcher.new(remote)

connection = fetcher.send(:connection)
connection.request(uri)
end

def skip_unless_support_pqc
without_pqc_support do |message|
skip message
end
end
end
6 changes: 3 additions & 3 deletions spec/bundler/fetcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,14 @@
end
end

context "when bunder ssl ssl configuration is set" do
context "when bunder ssl configuration is set" do
before do
cert = File.join(Spec::Path.tmpdir, "cert")
File.open(cert, "w") {|f| f.write "PEM" }
allow(Bundler.settings).to receive(:[]).and_return(nil)
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(cert)
expect(OpenSSL::X509::Certificate).to receive(:new).with("PEM").and_return("cert")
expect(OpenSSL::PKey::RSA).to receive(:new).with("PEM").and_return("key")
expect(OpenSSL::PKey).to receive(:read).with("PEM").and_return("key")
end
after do
FileUtils.rm File.join(Spec::Path.tmpdir, "cert")
Expand All @@ -120,7 +120,7 @@
)
expect(File).to receive(:read).and_return("")
expect(OpenSSL::X509::Certificate).to receive(:new).and_return("cert")
expect(OpenSSL::PKey::RSA).to receive(:new).and_return("key")
expect(OpenSSL::PKey).to receive(:read).and_return("key")
store = double("ca store")
expect(store).to receive(:add_file)
expect(OpenSSL::X509::Store).to receive(:new).and_return(store)
Expand Down
2 changes: 2 additions & 0 deletions spec/bundler/installer/parallel_installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
describe "priority queue" do
before do
require "support/artifice/compact_index"
Artifice.activate_with(CompactIndexAPI)

@previous_client = Gem::Request::ConnectionPools.client
Gem::Request::ConnectionPools.client = Gem::Net::HTTP
Expand Down Expand Up @@ -98,6 +99,7 @@
end

require "support/artifice/compact_index"
Artifice.activate_with(CompactIndexAPI)

@previous_client = Gem::Request::ConnectionPools.client
Gem::Request::ConnectionPools.client = Gem::Net::HTTP
Expand Down
4 changes: 3 additions & 1 deletion spec/support/artifice/helpers/artifice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ module Artifice
def self.activate_with(endpoint)
require_relative "rack_request"

@original_net_http = ::Gem::Net::HTTP
Net::HTTP.endpoint = endpoint
replace_net_http(Artifice::Net::HTTP)
end

# Deactivate the Artifice replacement.
def self.deactivate
replace_net_http(::Gem::Net::HTTP)
replace_net_http(@original_net_http) if @original_net_http
@original_net_http = nil
end

def self.replace_net_http(value)
Expand Down
4 changes: 4 additions & 0 deletions spec/support/path.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ def spec_dir
@spec_dir ||= source_root.join(ruby_core? ? "spec/bundler" : "spec")
end

def rubygems_test_dir
@rubygems_test_dir ||= source_root.join("test/rubygems")
end

def man_dir
@man_dir ||= lib_dir.join("bundler/man")
end
Expand Down
1 change: 1 addition & 0 deletions spec/support/shards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ module Shards
"spec/install/gems/no_install_plugin_spec.rb",
"spec/bundler/override_spec.rb",
"spec/install/gemfile/override_spec.rb",
"spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb",
],
}.freeze
end
Expand Down
154 changes: 154 additions & 0 deletions test/rubygems/local_ssl_server_utilities.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# frozen_string_literal: true

# This file can be loaded by RubyGems test-unit files and Bundler rspec files.
# Don't add test-unit or rspec dependent logic in this file.

require "socket"
require "openssl"

module Gem::LocalSSLServerUtilities
CERTS_DIR = __dir__

def certs_dir
CERTS_DIR
end

def initialize_ssl_server
@ssl_server_thread = nil
@ssl_server = nil
end

def stop_ssl_server
if @ssl_server_thread
@ssl_server_thread.kill.join
@ssl_server_thread = nil
end
if @ssl_server
@ssl_server.close
@ssl_server = nil
end
end

# mode:
# :non_pqc - Run single server with PQC-unsupported RSA (default)
# :pqc - Run single server with PQC-supported key exchange,
# X25519MLKEM768, and PQC-supported certificate, ML-DSA-65
def start_ssl_server(config = {})
mode = config.fetch(:mode, :non_pqc)
server = TCPServer.new(0)
ctx = OpenSSL::SSL::SSLContext.new

case mode
when :non_pqc
ctx.cert = cert("ssl_cert.pem")
ctx.key = key("ssl_key.pem")
ctx.ca_file = File.join(certs_dir, "ca_cert.pem")
when :pqc
ctx.cert = cert("mldsa65_ssl_cert.pem")
ctx.key = key("mldsa65_ssl_key.pem")
ctx.ca_file = File.join(certs_dir, "mldsa65_ca_cert.pem")
ctx.groups = "X25519MLKEM768"
end

ctx.verify_mode = config[:verify_mode] if config[:verify_mode]
@ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)
@ssl_server_thread = Thread.new do
loop do
ssl_client = @ssl_server.accept
Thread.new(ssl_client) do |client|
handle_request(client)
ensure
client.close
end
rescue OpenSSL::SSL::SSLError
# Ignore SSL errors because we're testing them implicitly
end
end
@ssl_server
end

def handle_request(client)
request = client.gets
if request&.start_with?("GET /yaml")
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\n\r\n--- true\n"
elsif request&.start_with?("GET /insecure_redirect")
location = request.match(/to=([^ ]+)/)[1]
client.print "HTTP/1.1 301 Moved Permanently\r\nLocation: #{location}\r\n\r\n"
else
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
end
end

def cert(filename)
OpenSSL::X509::Certificate.new(File.read(File.join(certs_dir, filename)))
end

def key(filename)
OpenSSL::PKey.read(File.read(File.join(certs_dir, filename)))
end

def without_pqc_support(&block)
# PQC algorithms ML-KEM and ML-DSA require OpenSSL >= 3.5.
# https://openssl-library.org/post/2025-04-08-openssl-35-final-release/
unless OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30500000
yield "PQC algorithms require OpenSSL >= 3.5"
return
end
# ctx.groups (OpenSSL::SSL::SSLContext#groups) used in start_ssl_server
# mode :pqc requires Ruby OpenSSL >= 4.0.
unless Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new("4.0")
yield "PQC test requires Ruby OpenSSL >= 4.0"
return
end
# Even with a new enough OpenSSL, the runtime may keep PQC groups and
# signature algorithms out of its default negotiation lists (for example
# RHEL's system-wide crypto policies). The PQC server forces both, while
# the gem fetcher connects with the default client configuration, so a
# real loopback handshake is the only reliable way to tell whether this
# environment can negotiate PQC at all.
unless Gem::LocalSSLServerUtilities.support_pqc_handshake?
yield "PQC handshake is not available in this OpenSSL configuration"
end
end

# Probe an actual PQC handshake between a forced-PQC server and a
# default-configured client, mirroring what the integration tests exercise.
# Memoized so the probe runs at most once per process.
def self.support_pqc_handshake?
return @support_pqc_handshake unless @support_pqc_handshake.nil?

@support_pqc_handshake = probe_pqc_handshake
end

def self.probe_pqc_handshake
server = TCPServer.new("127.0.0.1", 0)
ctx = OpenSSL::SSL::SSLContext.new
ctx.cert = OpenSSL::X509::Certificate.new(File.read(File.join(CERTS_DIR, "mldsa65_ssl_cert.pem")))
ctx.key = OpenSSL::PKey.read(File.read(File.join(CERTS_DIR, "mldsa65_ssl_key.pem")))
ctx.groups = "X25519MLKEM768"
ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)

port = server.addr[1]
server_thread = Thread.new do
client = ssl_server.accept
client.close
rescue OpenSSL::OpenSSLError
nil
end

client_ctx = OpenSSL::SSL::SSLContext.new
client_ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
socket = TCPSocket.new("127.0.0.1", port)
ssl = OpenSSL::SSL::SSLSocket.new(socket, client_ctx)
ssl.connect
ssl.close
true
rescue OpenSSL::OpenSSLError, SystemCallError
false
ensure
server_thread&.join(5)
server_thread&.kill if server_thread&.alive?
ssl_server&.close
server&.close
end
end
Loading