Skip to content

Commit 1f63f18

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 f12d7a2 commit 1f63f18

5 files changed

Lines changed: 192 additions & 91 deletions

File tree

bundler/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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "../../../test/rubygems/local_ssl_server_utilities"
4+
require "bundler/fetcher"
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+
skip "PQC is not supported" unless pqc_supported?
77+
end
78+
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)
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
def certs_dir
11+
__dir__
12+
end
13+
14+
def initialize_ssl_server
15+
@ssl_server_thread = nil
16+
@ssl_server = nil
17+
end
18+
19+
def stop_ssl_server
20+
if @ssl_server_thread
21+
@ssl_server_thread.kill.join
22+
@ssl_server_thread = nil
23+
end
24+
if @ssl_server
25+
@ssl_server.close
26+
@ssl_server = nil
27+
end
28+
end
29+
30+
# mode:
31+
# :non_pqc - Run single server with PQC-unsupported RSA (default)
32+
# :pqc - Run single server with PQC-supported key exchange,
33+
# X25519MLKEM768, and PQC-supported certificate, ML-DSA-65
34+
def start_ssl_server(config = {})
35+
mode = config.fetch(:mode, :non_pqc)
36+
server = TCPServer.new(0)
37+
ctx = OpenSSL::SSL::SSLContext.new
38+
39+
case mode
40+
when :non_pqc
41+
ctx.cert = cert("ssl_cert.pem")
42+
ctx.key = key("ssl_key.pem")
43+
ctx.ca_file = File.join(certs_dir, "ca_cert.pem")
44+
when :pqc
45+
ctx.cert = cert("mldsa65_ssl_cert.pem")
46+
ctx.key = key("mldsa65_ssl_key.pem")
47+
ctx.ca_file = File.join(certs_dir, "mldsa65_ca_cert.pem")
48+
ctx.groups = "X25519MLKEM768"
49+
end
50+
51+
ctx.verify_mode = config[:verify_mode] if config[:verify_mode]
52+
@ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)
53+
@ssl_server_thread = Thread.new do
54+
loop do
55+
ssl_client = @ssl_server.accept
56+
Thread.new(ssl_client) do |client|
57+
handle_request(client)
58+
ensure
59+
client.close
60+
end
61+
rescue OpenSSL::SSL::SSLError
62+
# Ignore SSL errors because we're testing them implicitly
63+
end
64+
end
65+
@ssl_server
66+
end
67+
68+
def handle_request(client)
69+
request = client.gets
70+
if request&.start_with?("GET /yaml")
71+
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\n\r\n--- true\n"
72+
elsif request&.start_with?("GET /insecure_redirect")
73+
location = request.match(/to=([^ ]+)/)[1]
74+
client.print "HTTP/1.1 301 Moved Permanently\r\nLocation: #{location}\r\n\r\n"
75+
else
76+
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
77+
end
78+
end
79+
80+
def cert(filename)
81+
OpenSSL::X509::Certificate.new(File.read(File.join(certs_dir, filename)))
82+
end
83+
84+
def key(filename)
85+
OpenSSL::PKey.read(File.read(File.join(certs_dir, filename)))
86+
end
87+
88+
def pqc_supported?
89+
# PQC algorithms ML-KEM and ML-DSA require OpenSSL >= 3.5.
90+
# https://openssl-library.org/post/2025-04-08-openssl-35-final-release/
91+
# ctx.groups (OpenSSL::SSL::SSLContext#groups) used in start_ssl_server
92+
# mode :pqc requires Ruby OpenSSL >= 4.0.
93+
OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30500000 &&
94+
Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new("4.0")
95+
end
96+
end
Lines changed: 14 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
# frozen_string_literal: true
22

33
require_relative "helper"
4-
require "socket"
5-
require "openssl"
4+
require_relative "local_ssl_server_utilities"
65

76
unless Gem::HAVE_OPENSSL
87
warn "Skipping Gem::RemoteFetcher tests. openssl not found."
@@ -13,28 +12,21 @@
1312

1413
class TestGemRemoteFetcherLocalSSLServer < Gem::TestCase
1514
include Gem::DefaultUserInteraction
15+
include Gem::LocalSSLServerUtilities
1616

1717
def setup
1818
super
19-
@ssl_server_thread = nil
20-
@ssl_server = nil
19+
initialize_ssl_server
2120
end
2221

2322
def teardown
24-
if @ssl_server_thread
25-
@ssl_server_thread.kill.join
26-
@ssl_server_thread = nil
27-
end
28-
if @ssl_server
29-
@ssl_server.close
30-
@ssl_server = nil
31-
end
23+
stop_ssl_server
3224
super
3325
end
3426

3527
def test_ssl_connection
3628
ssl_server = start_ssl_server
37-
temp_ca_cert = File.join(__dir__, "ca_cert.pem")
29+
temp_ca_cert = File.join(certs_dir, "ca_cert.pem")
3830
with_configured_fetcher(":ssl_ca_cert: #{temp_ca_cert}") do |fetcher|
3931
fetcher.fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
4032
end
@@ -44,7 +36,7 @@ def test_pqc_ssl_connection
4436
omit_unless_support_pqc
4537

4638
ssl_server = start_ssl_server(mode: :pqc)
47-
temp_ca_cert = File.join(__dir__, "mldsa65_ca_cert.pem")
39+
temp_ca_cert = File.join(certs_dir, "mldsa65_ca_cert.pem")
4840
with_configured_fetcher(":ssl_ca_cert: #{temp_ca_cert}") do |fetcher|
4941
fetcher.fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
5042
end
@@ -55,8 +47,8 @@ def test_ssl_client_cert_auth_connection
5547
{ verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT }
5648
)
5749

58-
temp_ca_cert = File.join(__dir__, "ca_cert.pem")
59-
temp_client_cert = File.join(__dir__, "client.pem")
50+
temp_ca_cert = File.join(certs_dir, "ca_cert.pem")
51+
temp_client_cert = File.join(certs_dir, "client.pem")
6052

6153
with_configured_fetcher(
6254
":ssl_ca_cert: #{temp_ca_cert}\n" \
@@ -74,8 +66,8 @@ def test_pqc_ssl_client_cert_auth_connection
7466
verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
7567
)
7668

77-
temp_ca_cert = File.join(__dir__, "mldsa65_ca_cert.pem")
78-
temp_client_cert = File.join(__dir__, "mldsa65_client.pem")
69+
temp_ca_cert = File.join(certs_dir, "mldsa65_ca_cert.pem")
70+
temp_client_cert = File.join(certs_dir, "mldsa65_client.pem")
7971

8072
with_configured_fetcher(
8173
":ssl_ca_cert: #{temp_ca_cert}\n" \
@@ -90,8 +82,8 @@ def test_do_not_allow_invalid_client_cert_auth_connection
9082
{ verify_mode: OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT }
9183
)
9284

93-
temp_ca_cert = File.join(__dir__, "ca_cert.pem")
94-
temp_client_cert = File.join(__dir__, "invalid_client.pem")
85+
temp_ca_cert = File.join(certs_dir, "ca_cert.pem")
86+
temp_client_cert = File.join(certs_dir, "invalid_client.pem")
9587

9688
with_configured_fetcher(
9789
":ssl_ca_cert: #{temp_ca_cert}\n" \
@@ -122,7 +114,7 @@ def test_ssl_connection_allow_verify_none
122114
def test_do_not_follow_insecure_redirect
123115
@server_uri = "http://example.com"
124116
ssl_server = start_ssl_server
125-
temp_ca_cert = File.join(__dir__, "ca_cert.pem")
117+
temp_ca_cert = File.join(certs_dir, "ca_cert.pem")
126118
expected_error_message =
127119
"redirecting to non-https resource: #{@server_uri} (https://localhost:#{ssl_server.addr[1]}/insecure_redirect?to=#{@server_uri})"
128120

@@ -164,72 +156,7 @@ def with_configured_fetcher(config_str = nil, &block)
164156
Gem.configuration = nil
165157
end
166158

167-
# mode:
168-
# :non_pqc - Run single server with PQC-unsupported RSA (default)
169-
# :pqc - Run single server with PQC-supported key exchange,
170-
# X25519MLKEM768, and PQC-supported certificate, ML-DSA-65
171-
def start_ssl_server(config = {})
172-
mode = config.fetch(:mode, :non_pqc)
173-
server = TCPServer.new(0)
174-
ctx = OpenSSL::SSL::SSLContext.new
175-
176-
case mode
177-
when :non_pqc
178-
ctx.cert = cert("ssl_cert.pem")
179-
ctx.key = key("ssl_key.pem")
180-
ctx.ca_file = File.join(__dir__, "ca_cert.pem")
181-
when :pqc
182-
ctx.cert = cert("mldsa65_ssl_cert.pem")
183-
ctx.key = key("mldsa65_ssl_key.pem")
184-
ctx.ca_file = File.join(__dir__, "mldsa65_ca_cert.pem")
185-
ctx.groups = "X25519MLKEM768"
186-
end
187-
188-
ctx.verify_mode = config[:verify_mode] if config[:verify_mode]
189-
@ssl_server = OpenSSL::SSL::SSLServer.new(server, ctx)
190-
@ssl_server_thread = Thread.new do
191-
loop do
192-
ssl_client = @ssl_server.accept
193-
Thread.new(ssl_client) do |client|
194-
handle_request(client)
195-
ensure
196-
client.close
197-
end
198-
rescue OpenSSL::SSL::SSLError
199-
# Ignore SSL errors because we're testing them implicitly
200-
end
201-
end
202-
@ssl_server
203-
end
204-
205-
def handle_request(client)
206-
request = client.gets
207-
if request.start_with?("GET /yaml")
208-
client.print "HTTP/1.1 200 OK\r\nContent-Type: text/yaml\r\n\r\n--- true\n"
209-
elsif request.start_with?("GET /insecure_redirect")
210-
location = request.match(/to=([^ ]+)/)[1]
211-
client.print "HTTP/1.1 301 Moved Permanently\r\nLocation: #{location}\r\n\r\n"
212-
else
213-
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
214-
end
215-
end
216-
217-
def cert(filename)
218-
OpenSSL::X509::Certificate.new(File.read(File.join(__dir__, filename)))
219-
end
220-
221-
def key(filename)
222-
OpenSSL::PKey.read(File.read(File.join(__dir__, filename)))
223-
end
224-
225159
def omit_unless_support_pqc
226-
# PQC algorithms ML-KEM and ML-DSA require OpenSSL >= 3.5.
227-
# https://openssl-library.org/post/2025-04-08-openssl-35-final-release/
228-
omit "PQC algorithms require OpenSSL >= 3.5" unless
229-
OpenSSL::OPENSSL_VERSION_NUMBER >= 0x30500000
230-
# ctx.groups (OpenSSL::SSL::SSLContext#groups) used in start_ssl_server
231-
# mode :pqc requires Ruby OpenSSL >= 4.0.
232-
omit "PQC test requires Ruby OpenSSL >= 4.0" unless
233-
Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new("4.0")
160+
omit "PQC is not supported" unless pqc_supported?
234161
end
235162
end if Gem::HAVE_OPENSSL

0 commit comments

Comments
 (0)