Skip to content

Commit defe1d5

Browse files
junarugaclaude
andcommitted
bundler: Fix Bundler::Fetcher for PQC support, adding integration connection tests
Created spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb adding non-PQC and PQC server/client connection integration tests. As "Bundler::Fetcher local SSL server #connection PQC connects with client cert auth" failed with the following error due to hardcoded `OpenSSL::PKey::RSA.new` in `Bundler::Fetcher#connection`, fixed it to support ML-DSA ssl_client_cert. ``` $ bin/rspec spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb ... Failures: 1) Bundler::Fetcher local SSL server #connection PQC connects with client cert auth Failure/Error: fetcher = Bundler::Fetcher.new(remote) OpenSSL::PKey::PKeyError: incorrect pkey type: UNDEF # /home/jaruga/.local/ruby-4.1.0-debug-3ef48ef9c8-openssl-4.1.0-7194354488/lib/ruby/4.1.0+1/openssl/pkey.rb:394:in 'OpenSSL::PKey::RSA#initialize' # /home/jaruga/.local/ruby-4.1.0-debug-3ef48ef9c8-openssl-4.1.0-7194354488/lib/ruby/4.1.0+1/openssl/pkey.rb:394:in 'Class#new' # /home/jaruga/.local/ruby-4.1.0-debug-3ef48ef9c8-openssl-4.1.0-7194354488/lib/ruby/4.1.0+1/openssl/pkey.rb:394:in 'OpenSSL::PKey::RSA.new' # ./bundler/lib/bundler/fetcher.rb:321:in 'Bundler::Fetcher#connection' # ./bundler/lib/bundler/fetcher.rb:140:in 'Bundler::Fetcher#initialize' # ./spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb:69:in 'RSpec::ExampleGroups::BundlerFetcherLocalSSLServer#fetch_path' # ./spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb:60:in 'block (4 levels) in <top (required)>' ... ``` Created test/rubygems/local_ssl_server_utilities.rb to manage utility methods called by RubyGems test-unit and Bundler rspec tests. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 96a6ddf commit defe1d5

7 files changed

Lines changed: 258 additions & 139 deletions

File tree

lib/bundler/fetcher.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def connection
318318
if ssl_client_cert
319319
pem = File.read(ssl_client_cert)
320320
con.cert = OpenSSL::X509::Certificate.new(pem)
321-
con.key = OpenSSL::PKey::RSA.new(pem)
321+
con.key = OpenSSL::PKey.read(pem)
322322
end
323323

324324
con.read_timeout = Fetcher.api_timeout
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# frozen_string_literal: true
2+
3+
require "bundler/fetcher"
4+
require Spec::Path.rubygems_test_dir.join("local_ssl_server_utilities")
5+
6+
RSpec.describe "Bundler::Fetcher local SSL server", if: Gem::HAVE_OPENSSL do
7+
include Gem::LocalSSLServerUtilities
8+
9+
before do
10+
initialize_ssl_server
11+
end
12+
13+
after do
14+
stop_ssl_server
15+
end
16+
17+
describe "#connection" do
18+
context "non-PQC" do
19+
it "connects" do
20+
ssl_server = start_ssl_server
21+
allow(Bundler.settings).to receive(:[]).and_call_original
22+
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "ca_cert.pem"))
23+
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
24+
expect(response.code).to eq("200")
25+
end
26+
27+
it "connects with client cert auth" do
28+
ssl_server = start_ssl_server(
29+
verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
30+
)
31+
allow(Bundler.settings).to receive(:[]).and_call_original
32+
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "ca_cert.pem"))
33+
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(File.join(certs_dir, "client.pem"))
34+
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
35+
expect(response.code).to eq("200")
36+
end
37+
end
38+
39+
context "PQC" do
40+
before do
41+
skip_unless_support_pqc
42+
end
43+
44+
it "connects" do
45+
ssl_server = start_ssl_server(mode: :pqc)
46+
allow(Bundler.settings).to receive(:[]).and_call_original
47+
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "mldsa65_ca_cert.pem"))
48+
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
49+
expect(response.code).to eq("200")
50+
end
51+
52+
it "connects with client cert auth" do
53+
ssl_server = start_ssl_server(
54+
mode: :pqc,
55+
verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
56+
)
57+
allow(Bundler.settings).to receive(:[]).and_call_original
58+
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(File.join(certs_dir, "mldsa65_ca_cert.pem"))
59+
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(File.join(certs_dir, "mldsa65_client.pem"))
60+
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
61+
expect(response.code).to eq("200")
62+
end
63+
end
64+
end
65+
66+
def fetch_path(uri)
67+
uri = Gem::URI(uri)
68+
remote = double("remote", uri: uri, original_uri: nil)
69+
fetcher = Bundler::Fetcher.new(remote)
70+
71+
connection = fetcher.send(:connection)
72+
connection.request(uri)
73+
end
74+
75+
def skip_unless_support_pqc
76+
without_pqc_support do |message|
77+
skip message
78+
end
79+
end
80+
end

spec/bundler/fetcher_spec.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@
9393
end
9494
end
9595

96-
context "when bunder ssl ssl configuration is set" do
96+
context "when bunder ssl configuration is set" do
9797
before do
9898
cert = File.join(Spec::Path.tmpdir, "cert")
9999
File.open(cert, "w") {|f| f.write "PEM" }
100100
allow(Bundler.settings).to receive(:[]).and_return(nil)
101101
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(cert)
102102
expect(OpenSSL::X509::Certificate).to receive(:new).with("PEM").and_return("cert")
103-
expect(OpenSSL::PKey::RSA).to receive(:new).with("PEM").and_return("key")
103+
expect(OpenSSL::PKey).to receive(:read).with("PEM").and_return("key")
104104
end
105105
after do
106106
FileUtils.rm File.join(Spec::Path.tmpdir, "cert")
@@ -120,7 +120,7 @@
120120
)
121121
expect(File).to receive(:read).and_return("")
122122
expect(OpenSSL::X509::Certificate).to receive(:new).and_return("cert")
123-
expect(OpenSSL::PKey::RSA).to receive(:new).and_return("key")
123+
expect(OpenSSL::PKey).to receive(:read).and_return("key")
124124
store = double("ca store")
125125
expect(store).to receive(:add_file)
126126
expect(OpenSSL::X509::Store).to receive(:new).and_return(store)

spec/support/path.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ def spec_dir
7979
@spec_dir ||= source_root.join(ruby_core? ? "spec/bundler" : "spec")
8080
end
8181

82+
def rubygems_test_dir
83+
@rubygems_test_dir ||= source_root.join("test/rubygems")
84+
end
85+
8286
def man_dir
8387
@man_dir ||= lib_dir.join("bundler/man")
8488
end

spec/support/shards.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ module Shards
194194
"spec/install/gems/no_install_plugin_spec.rb",
195195
"spec/bundler/override_spec.rb",
196196
"spec/install/gemfile/override_spec.rb",
197+
"spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb",
197198
],
198199
}.freeze
199200
end
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# frozen_string_literal: true
2+
3+
# This file can be loaded by RubyGems test-unit files and Bundler rspec files.
4+
# Don't add test-unit or rspec dependent logic in this file.
5+
6+
require "socket"
7+
require "openssl"
8+
9+
module Gem::LocalSSLServerUtilities
10+
CERTS_DIR = __dir__
11+
12+
def certs_dir
13+
CERTS_DIR
14+
end
15+
16+
def initialize_ssl_server
17+
@ssl_server_thread = nil
18+
@ssl_server = nil
19+
end
20+
21+
def stop_ssl_server
22+
if @ssl_server_thread
23+
@ssl_server_thread.kill.join
24+
@ssl_server_thread = nil
25+
end
26+
if @ssl_server
27+
@ssl_server.close
28+
@ssl_server = nil
29+
end
30+
end
31+
32+
# mode:
33+
# :non_pqc - Run single server with PQC-unsupported RSA (default)
34+
# :pqc - Run single server with PQC-supported key exchange,
35+
# X25519MLKEM768, and PQC-supported certificate, ML-DSA-65
36+
def start_ssl_server(config = {})
37+
mode = config.fetch(:mode, :non_pqc)
38+
server = TCPServer.new(0)
39+
ctx = OpenSSL::SSL::SSLContext.new
40+
41+
case mode
42+
when :non_pqc
43+
ctx.cert = cert("ssl_cert.pem")
44+
ctx.key = key("ssl_key.pem")
45+
ctx.ca_file = File.join(certs_dir, "ca_cert.pem")
46+
when :pqc
47+
ctx.cert = cert("mldsa65_ssl_cert.pem")
48+
ctx.key = key("mldsa65_ssl_key.pem")
49+
ctx.ca_file = File.join(certs_dir, "mldsa65_ca_cert.pem")
50+
ctx.groups = "X25519MLKEM768"
51+
end
52+
53+
ctx.verify_mode = config[:verify_mode] if config[:verify_mode]
54+
@ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)
55+
@ssl_server_thread = Thread.new do
56+
loop do
57+
ssl_client = @ssl_server.accept
58+
Thread.new(ssl_client) do |client|
59+
handle_request(client)
60+
ensure
61+
client.close
62+
end
63+
rescue OpenSSL::SSL::SSLError
64+
# Ignore SSL errors because we're testing them implicitly
65+
end
66+
end
67+
@ssl_server
68+
end
69+
70+
def handle_request(client)
71+
request = client.gets
72+
if request&.start_with?("GET /yaml")
73+
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\n\r\n--- true\n"
74+
elsif request&.start_with?("GET /insecure_redirect")
75+
location = request.match(/to=([^ ]+)/)[1]
76+
client.print "HTTP/1.1 301 Moved Permanently\r\nLocation: #{location}\r\n\r\n"
77+
else
78+
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
79+
end
80+
end
81+
82+
def cert(filename)
83+
OpenSSL::X509::Certificate.new(File.read(File.join(certs_dir, filename)))
84+
end
85+
86+
def key(filename)
87+
OpenSSL::PKey.read(File.read(File.join(certs_dir, filename)))
88+
end
89+
90+
def without_pqc_support(&block)
91+
# PQC algorithms ML-KEM and ML-DSA require OpenSSL >= 3.5.
92+
# https://openssl-library.org/post/2025-04-08-openssl-35-final-release/
93+
unless OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30500000
94+
yield "PQC algorithms require OpenSSL >= 3.5"
95+
return
96+
end
97+
# ctx.groups (OpenSSL::SSL::SSLContext#groups) used in start_ssl_server
98+
# mode :pqc requires Ruby OpenSSL >= 4.0.
99+
unless Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new("4.0")
100+
yield "PQC test requires Ruby OpenSSL >= 4.0"
101+
return
102+
end
103+
# Even with a new enough OpenSSL, the runtime may keep PQC groups and
104+
# signature algorithms out of its default negotiation lists (for example
105+
# RHEL's system-wide crypto policies). The PQC server forces both, while
106+
# the gem fetcher connects with the default client configuration, so a
107+
# real loopback handshake is the only reliable way to tell whether this
108+
# environment can negotiate PQC at all.
109+
unless Gem::LocalSSLServerUtilities.support_pqc_handshake?
110+
yield "PQC handshake is not available in this OpenSSL configuration"
111+
end
112+
end
113+
114+
# Probe an actual PQC handshake between a forced-PQC server and a
115+
# default-configured client, mirroring what the integration tests exercise.
116+
# Memoized so the probe runs at most once per process.
117+
def self.support_pqc_handshake?
118+
return @support_pqc_handshake unless @support_pqc_handshake.nil?
119+
120+
@support_pqc_handshake = probe_pqc_handshake
121+
end
122+
123+
def self.probe_pqc_handshake
124+
server = TCPServer.new("127.0.0.1", 0)
125+
ctx = OpenSSL::SSL::SSLContext.new
126+
ctx.cert = OpenSSL::X509::Certificate.new(File.read(File.join(CERTS_DIR, "mldsa65_ssl_cert.pem")))
127+
ctx.key = OpenSSL::PKey.read(File.read(File.join(CERTS_DIR, "mldsa65_ssl_key.pem")))
128+
ctx.groups = "X25519MLKEM768"
129+
ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)
130+
131+
port = server.addr[1]
132+
server_thread = Thread.new do
133+
client = ssl_server.accept
134+
client.close
135+
rescue OpenSSL::OpenSSLError
136+
nil
137+
end
138+
139+
client_ctx = OpenSSL::SSL::SSLContext.new
140+
client_ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
141+
socket = TCPSocket.new("127.0.0.1", port)
142+
ssl = OpenSSL::SSL::SSLSocket.new(socket, client_ctx)
143+
ssl.connect
144+
ssl.close
145+
true
146+
rescue OpenSSL::OpenSSLError, SystemCallError
147+
false
148+
ensure
149+
server_thread&.join(5)
150+
server_thread&.kill if server_thread&.alive?
151+
ssl_server&.close
152+
server&.close
153+
end
154+
end

0 commit comments

Comments
 (0)