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
8 changes: 6 additions & 2 deletions lib/rubygems/commands/cert_command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ def initialize
end

add_option("-A", "--key-algorithm ALGORITHM",
"Select which key algorithm to use for --build") do |algorithm, options|
"Select key algorithm for --build from",
"RSA, DSA, EC, ML-DSA-44, ML-DSA-65,",
"or ML-DSA-87. Defaults to "\
"#{Gem::Security::DEFAULT_KEY_ALGORITHM}.") do |algorithm, options|
options[:key_algorithm] = algorithm
end

Expand Down Expand Up @@ -100,7 +103,8 @@ def open_private_key(key_file)
rescue Errno::ENOENT
raise Gem::OptionParser::InvalidArgument, "#{key_file}: does not exist"
rescue OpenSSL::PKey::PKeyError, ArgumentError
raise Gem::OptionParser::InvalidArgument, "#{key_file}: invalid RSA, DSA, or EC key"
raise Gem::OptionParser::InvalidArgument, "#{key_file}: invalid "\
"RSA, DSA, EC, ML-DSA-44, ML-DSA-65, or ML-DSA-87 key"
end

def execute
Expand Down
99 changes: 88 additions & 11 deletions lib/rubygems/security.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@
# certificate for EMAIL_ADDR
# -C, --certificate CERT Signing certificate for --sign
# -K, --private-key KEY Key for --sign or --build
# -A, --key-algorithm ALGORITHM Select key algorithm for --build from RSA, DSA, or EC. Defaults to RSA.
# -A, --key-algorithm ALGORITHM Select key algorithm for --build from
# RSA, DSA, EC, ML-DSA-44, ML-DSA-65,
# or ML-DSA-87. Defaults to RSA.
# -s, --sign CERT Signs CERT with the key from -K
# and the certificate from -C
# -d, --days NUMBER_OF_DAYS Days before the certificate expires
Expand Down Expand Up @@ -351,6 +353,23 @@ class Exception < Gem::Exception; end

EC_NAME = "secp384r1"

##
# ML-DSA algorithm names to use when building a key pair.
# ML-DSA-44: NIST security strength category 2, signature size 2420 bytes
# ML-DSA-65: NIST security strength category 3, signature size 3309 bytes
# ML-DSA-87: NIST security strength category 5, signature size 4627 bytes
# See NIST FIPS 204 Section 4 (Parameter Sets).
# https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.204.pdf
# See Security (Evaluation Criteria) - 4.A.5 Security Strength Categories.
# https://csrc.nist.gov/projects/post-quantum-cryptography/post-quantum-cryptography-standardization/evaluation-criteria/security-(evaluation-criteria)

ML_DSA_44_NAME = "ML-DSA-44"
ML_DSA_65_NAME = "ML-DSA-65"
ML_DSA_87_NAME = "ML-DSA-87"

ML_DSA_NAMES = [ML_DSA_44_NAME, ML_DSA_65_NAME, ML_DSA_87_NAME].freeze
private_constant :ML_DSA_NAMES

##
# Cipher used to encrypt the key pair used to sign gems.
# Must be in the list returned by OpenSSL::Cipher.ciphers
Expand All @@ -371,14 +390,12 @@ class Exception < Gem::Exception; end
# The default set of extensions are:
#
# * The certificate is not a certificate authority
# * The key for the certificate may be used for key and data encipherment
# and digital signatures
# * The key for the certificate may be used for digital signatures
# * The certificate contains a subject key identifier

EXTENSIONS = {
"basicConstraints" => "CA:FALSE",
"keyUsage" =>
"keyEncipherment,dataEncipherment,digitalSignature",
"keyUsage" => "digitalSignature",
"subjectKeyIdentifier" => "hash",
}.freeze

Expand Down Expand Up @@ -464,25 +481,77 @@ def self.create_digest(algorithm = DIGEST_NAME)
end

##
# Creates a new key pair of the specified +algorithm+. RSA, DSA, and EC
# are supported.
# Creates a new key pair of the specified +algorithm+. RSA, DSA, EC,
# ML-DSA-44, ML-DSA-65, and ML-DSA-87 are supported.

def self.create_key(algorithm)
if defined?(OpenSSL::PKey)
case algorithm.downcase
when "dsa"
OpenSSL::PKey::DSA.new(RSA_DSA_KEY_LENGTH)
when "rsa"
OpenSSL::PKey::RSA.new(RSA_DSA_KEY_LENGTH)
when "dsa"
OpenSSL::PKey::DSA.new(RSA_DSA_KEY_LENGTH)
when "ec"
OpenSSL::PKey::EC.generate(EC_NAME)
when "ml-dsa-44"
create_ml_dsa_key(ML_DSA_44_NAME)
when "ml-dsa-65"
create_ml_dsa_key(ML_DSA_65_NAME)
when "ml-dsa-87"
create_ml_dsa_key(ML_DSA_87_NAME)
else
raise Gem::Security::Exception,
"#{algorithm} algorithm not found. RSA, DSA, and EC algorithms are supported."
"#{algorithm} algorithm not found. RSA, DSA, EC, ML-DSA-44, "\
"ML-DSA-65, and ML-DSA-87 algorithms are supported."
end
end
end

##
# Creates an ML-DSA key pair of the +algorithm+ such as ML-DSA-65. ML-DSA
# requires OpenSSL >= 3.5 or an SSL library supporting ML-DSA.

def self.create_ml_dsa_key(algorithm)
OpenSSL::PKey.generate_key(algorithm)
rescue OpenSSL::PKey::PKeyError
raise Gem::Security::Exception,
"#{algorithm} key generation failed: #{algorithm} requires "\
"OpenSSL >= 3.5 or an SSL library supporting ML-DSA."
end
private_class_method :create_ml_dsa_key

##
# Returns whether +key+ uses ML-DSA. OpenSSL::PKey::PKey#oid raises for the
# provider-backed keys ML-DSA uses, so the algorithm is read from the
# SubjectPublicKeyInfo instead.

def self.ml_dsa_key?(key)
algorithm = OpenSSL::ASN1.decode(key.public_to_der).value.first.value.first
ML_DSA_NAMES.include?(algorithm.ln)
rescue OpenSSL::ASN1::ASN1Error, OpenSSL::PKey::PKeyError, NoMethodError
false
end
private_class_method :ml_dsa_key?

##
# Returns whether the +key+ requires an explicit digest algorithm for signing
# and verification. ML-DSA has a built-in digest and does not accept one.
# Any other algorithm raises, since a gem carries no record of how its
# signature was produced and RubyGems must not guess.

def self.digest_required?(key)
case key
when OpenSSL::PKey::RSA, OpenSSL::PKey::DSA, OpenSSL::PKey::EC
true
else
return false if ml_dsa_key?(key)

raise Gem::Security::Exception,
"unsupported key algorithm. RSA, DSA, EC, ML-DSA-44, ML-DSA-65, and "\
"ML-DSA-87 keys are supported."
end
end

##
# Turns +email_address+ into an OpenSSL::X509::Name

Expand Down Expand Up @@ -562,11 +631,19 @@ def self.sign(certificate, signing_key, signing_cert, age = ONE_YEAR, extensions
signed = create_cert signee_subject, signee_key, age, extensions, serial
signed.issuer = signing_cert.subject

digest_name = Gem::Security::DIGEST_NAME if digest_required?(signing_key)

begin
signed.sign signing_key, Gem::Security::DIGEST_NAME
signed.sign signing_key, digest_name
rescue OpenSSL::PKey::PKeyError, ArgumentError
raise Gem::Security::Exception,
"incorrect signing key for signing"
# Ruby OpenSSL only accepts the nil digest ML-DSA needs from 3.3 on.
rescue TypeError
raise if digest_name

raise Gem::Security::Exception,
"certificate signing failed: ML-DSA requires Ruby OpenSSL >= 3.3."
end
end

Expand Down
10 changes: 9 additions & 1 deletion lib/rubygems/security/policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ def check_chain(chain, time)
# the +digest+ algorithm.

def check_data(public_key, digest, signature, data)
filtered_digest = digest if Gem::Security.digest_required?(public_key)

raise Gem::Security::Exception, "invalid signature" unless
public_key.verify digest, signature, data.digest
public_key.verify filtered_digest, signature, data.digest

true
end
Expand Down Expand Up @@ -268,6 +270,12 @@ def verify(chain, key = nil, digests = {}, signatures = {}, full_name = "(unknow
end

true
# NotImplementedError: JRuby's Ruby OpenSSL raises it for ML-DSA.
rescue OpenSSL::X509::CertificateError, NotImplementedError
raise Gem::Security::Exception,
"certificate verification failed: The certificate may use an algorithm "\
"such as ML-DSA that the installed OpenSSL does not support. ML-DSA "\
"requires OpenSSL >= 3.5 or an SSL library supporting ML-DSA."
end

##
Expand Down
12 changes: 10 additions & 2 deletions lib/rubygems/security/signer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,14 @@ def initialize(key, cert_chain, passphrase = nil, options = {})
@digest_algorithm = Gem::Security.create_digest(@digest_name)

if @key && !@key.is_a?(OpenSSL::PKey::PKey)
@key = OpenSSL::PKey.read(File.read(@key), @passphrase)
begin
@key = OpenSSL::PKey.read(File.read(@key), @passphrase)
rescue OpenSSL::PKey::PKeyError
raise Gem::Security::Exception,
"private key could not be loaded: The key may use an algorithm "\
"such as ML-DSA that the installed OpenSSL does not support. "\
"ML-DSA requires OpenSSL >= 3.5 or an SSL library supporting ML-DSA."
end
end

if @cert_chain
Expand Down Expand Up @@ -152,7 +159,8 @@ def sign(data)

Gem::Security::SigningPolicy.verify @cert_chain, @key, {}, {}, full_name

@key.sign @digest_algorithm.new, data
digest = @digest_algorithm.new if Gem::Security.digest_required?(@key)
@key.sign digest, data
end

##
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
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(Gem::PemUtilities::CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(Gem::PEMUtilities::CA_CERT_FILE)
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
Expand All @@ -30,8 +30,8 @@
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(Gem::PemUtilities::CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(Gem::PemUtilities::CLIENT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(Gem::PEMUtilities::CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(Gem::PEMUtilities::CLIENT_FILE)
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
Expand All @@ -45,7 +45,7 @@
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(Gem::PemUtilities::MLDSA65_CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(Gem::PEMUtilities::MLDSA65_CA_CERT_FILE)
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
Expand All @@ -56,8 +56,8 @@
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(Gem::PemUtilities::MLDSA65_CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(Gem::PemUtilities::MLDSA65_CLIENT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_ca_cert).and_return(Gem::PEMUtilities::MLDSA65_CA_CERT_FILE)
allow(Bundler.settings).to receive(:[]).with(:ssl_client_cert).and_return(Gem::PEMUtilities::MLDSA65_CLIENT_FILE)
response = fetch_path("https://localhost:#{ssl_server.addr[1]}/yaml")
expect(response.code).to eq("200")
end
Expand Down
30 changes: 29 additions & 1 deletion test/rubygems/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
require_relative "mock_gem_ui"
require_relative "pem_utilities"
require_relative "fake_credential_backend"
require_relative "pqc_utilities"

# JRuby on Windows raises TypeError inside File.symlink (the wincode helper
# trips on a nil path), so any test that exercises Gem::Installer's symlink
Expand Down Expand Up @@ -1681,7 +1682,34 @@ def prefetch(reqs) # :nodoc:
end
end

include Gem::PemUtilities
include Gem::PEMUtilities

include Gem::PQCUtilities

def omit_unless_support_pqc
without_pqc_support do |message|
omit message
end
end

def omit_unless_support_ml_dsa_key
omit "OpenSSL does not support ML-DSA" unless
Gem::PQCUtilities.support_ml_dsa_key?
end

def omit_unless_support_ml_dsa_cert
omit "Ruby OpenSSL cannot sign a certificate with an ML-DSA key" unless
Gem::PQCUtilities.support_ml_dsa_cert?
end

def omit_if_support_ml_dsa_cert
omit "Ruby OpenSSL can sign a certificate with an ML-DSA key" if
Gem::PQCUtilities.support_ml_dsa_cert?
end

def omit_if_support_ml_dsa_key
omit "OpenSSL supports ML-DSA" if Gem::PQCUtilities.support_ml_dsa_key?
end
end

# https://github.com/seattlerb/minitest/blob/13c48a03d84a2a87855a4de0c959f96800100357/lib/minitest/mock.rb#L192
Expand Down
70 changes: 4 additions & 66 deletions test/rubygems/local_ssl_server_utilities.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
require "socket"
require "openssl"
require_relative "pem_utilities"
require_relative "pqc_utilities"

module Gem::LocalSSLServerUtilities
include Gem::PemUtilities
include Gem::PEMUtilities
include Gem::PQCUtilities

def initialize_ssl_server
@ssl_server_thread = nil
@ssl_server = nil
Expand Down Expand Up @@ -74,69 +77,4 @@ def handle_request(client)
client.print "HTTP/1.1 404 Not Found\r\n\r\n"
end
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 = Gem::PemUtilities::MLDSA65_SSL_CERT
ctx.key = Gem::PemUtilities::MLDSA65_SSL_KEY
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