diff --git a/lib/rubygems/commands/cert_command.rb b/lib/rubygems/commands/cert_command.rb
index 09ae43e35f68..1f19b26b3349 100644
--- a/lib/rubygems/commands/cert_command.rb
+++ b/lib/rubygems/commands/cert_command.rb
@@ -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
@@ -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
diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb
index 6b46a483fd1c..d1fc7d09f1cc 100644
--- a/lib/rubygems/security.rb
+++ b/lib/rubygems/security.rb
@@ -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
@@ -351,6 +353,20 @@ 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"
+
##
# Cipher used to encrypt the key pair used to sign gems.
# Must be in the list returned by OpenSSL::Cipher.ciphers
@@ -464,25 +480,41 @@ 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"
+ OpenSSL::PKey.generate_key(ML_DSA_44_NAME)
+ when "ml-dsa-65"
+ OpenSSL::PKey.generate_key(ML_DSA_65_NAME)
+ when "ml-dsa-87"
+ OpenSSL::PKey.generate_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
+ ##
+ # 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.
+
+ def self.digest_required?(key)
+ key.is_a?(OpenSSL::PKey::RSA) || key.is_a?(OpenSSL::PKey::DSA) ||
+ key.is_a?(OpenSSL::PKey::EC)
+ end
+
##
# Turns +email_address+ into an OpenSSL::X509::Name
@@ -562,8 +594,10 @@ 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"
diff --git a/lib/rubygems/security/policy.rb b/lib/rubygems/security/policy.rb
index 03ddbe530e48..7415f778c8bc 100644
--- a/lib/rubygems/security/policy.rb
+++ b/lib/rubygems/security/policy.rb
@@ -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
diff --git a/lib/rubygems/security/signer.rb b/lib/rubygems/security/signer.rb
index 044562137cb8..5fdbb6feb1f5 100644
--- a/lib/rubygems/security/signer.rb
+++ b/lib/rubygems/security/signer.rb
@@ -154,7 +154,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
##
diff --git a/spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb b/spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb
index 2a287af19587..c68eabae9a6b 100644
--- a/spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb
+++ b/spec/bundler/fetcher/gem_remote_fetcher_local_ssl_server_spec.rb
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb
index 174bd258168b..d40888fb7a7f 100644
--- a/test/rubygems/helper.rb
+++ b/test/rubygems/helper.rb
@@ -47,6 +47,7 @@
require "zlib"
require_relative "mock_gem_ui"
require_relative "pem_utilities"
+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
@@ -1659,7 +1660,15 @@ 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
end
# https://github.com/seattlerb/minitest/blob/13c48a03d84a2a87855a4de0c959f96800100357/lib/minitest/mock.rb#L192
diff --git a/test/rubygems/local_ssl_server_utilities.rb b/test/rubygems/local_ssl_server_utilities.rb
index d0d73dbd1219..95785b2573d4 100644
--- a/test/rubygems/local_ssl_server_utilities.rb
+++ b/test/rubygems/local_ssl_server_utilities.rb
@@ -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
@@ -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
diff --git a/test/rubygems/mldsa65_encrypted_private_key.pem b/test/rubygems/mldsa65_encrypted_private_key.pem
new file mode 100644
index 000000000000..2a5dc9bfc72a
--- /dev/null
+++ b/test/rubygems/mldsa65_encrypted_private_key.pem
@@ -0,0 +1,90 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+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
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/test/rubygems/mldsa65_private_key.pem b/test/rubygems/mldsa65_private_key.pem
new file mode 100644
index 000000000000..4ed082e159e3
--- /dev/null
+++ b/test/rubygems/mldsa65_private_key.pem
@@ -0,0 +1,88 @@
+-----BEGIN PRIVATE KEY-----
+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
+Cd87u43ecy3rA1B/njZMqpZB
+-----END PRIVATE KEY-----
diff --git a/test/rubygems/mldsa65_public_cert.pem b/test/rubygems/mldsa65_public_cert.pem
new file mode 100644
index 000000000000..de2163a48182
--- /dev/null
+++ b/test/rubygems/mldsa65_public_cert.pem
@@ -0,0 +1,118 @@
+-----BEGIN CERTIFICATE-----
+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
+hYmOkpTf4QAAAAAAAAAAAAAAAAAABAkUGx8p
+-----END CERTIFICATE-----
diff --git a/test/rubygems/mldsa65_public_cert_32.pem b/test/rubygems/mldsa65_public_cert_32.pem
new file mode 100644
index 000000000000..7b96b45644e4
--- /dev/null
+++ b/test/rubygems/mldsa65_public_cert_32.pem
@@ -0,0 +1,118 @@
+-----BEGIN CERTIFICATE-----
+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
+mAAAAAAAAAAAAAAAAAAAAAAAAAYIEBYgJQ==
+-----END CERTIFICATE-----
diff --git a/test/rubygems/mldsa65_public_key.pem b/test/rubygems/mldsa65_public_key.pem
new file mode 100644
index 000000000000..94549140383d
--- /dev/null
+++ b/test/rubygems/mldsa65_public_key.pem
@@ -0,0 +1,44 @@
+-----BEGIN PUBLIC KEY-----
+MIIHsjALBglghkgBZQMEAxIDggehAEAGqr+ZUzivoMR66erd0vDOkIhO8kE7PaBp
+pvWEvIVsJbaSAAOKEp85yeZyKHgrKA9188GJMTlXszeiodVFE2aYgv7UPBfoizB8
+wgKWzqJxDbmxaSnb1BHllYfhBpUu2bF5QRT5xH7cvHgXd1eleCP/UpSTn0A1RSF7
+accd7Bz2B6JaDeRGZEbpASR0DD89pE6rSrEsHt9K/ibDh+6/bdjQt7DwMwPEvKdD
+mB2sQmKl69eleEU7cvLdatSzwaOkqjFei+VLyp8pwgsk2ap9jbQVoiv4/Fblbszl
+VT01LbXKNUBY44VolYPtI+d0sMCYA6MIMiphiDCVAsM+/2xykYp7r1vEugrSY5Oy
+xIneBFSweTCijjFCyWVbfJR/WH2QBK+VWdrnXkyL8geL4V6+mcBGHY6jmAq9Twqo
+Re73BRKPEp0w96Feb8k/7KJnEkVZh9iBAMTgHdHEYGkYPP75zC5AJ9iDL1AmLynl
+6DKzF5ViWcfW8xNLccKNJJArMw36Oi+wBPOv0UK+Vxcr9GrSD2SJQD1hUjSBdjqR
+0G0ZrCPnaxgHsc2UwnQW/S8wL9lJGBQAKonrldXPTvk6uMqEfoQt9+Dj/gOK2v0z
+pUxhtL04U640tu+UBFKPq7VmXyVwZp0porUyGach4kq6E/5l5z8muhABvxFY7/IF
+y1Z4Uus+SFWHS8PvVn9+wdVwg7OGN9oXuNgkSaxXkeSoYj474AF32kaYlhwkZWby
+aDPtHSp+a+iIhDeUwKHOn1+9Qzk5/h7DCsg+lDzBrNU/0L+4mqHpt6QfmY4LvrAT
+QkLI5X3eHyigTGMEMIIEQuvS6tyAeDPT78aqpfYbNo0hKwNKZhOuLvRE2SUGflTf
+99q/PeMZoXXsYainHPuiwhfyHaK/1sBYXnUNumT4XGVNgEI5OAmkF20X21NOvBzY
+b8+Kh1EkykgNQktGmnBGI2iP+whmCCH+WvFlCiXl4wUjN2R6LUEDlntYk2bAionH
+Jh+PghOulk7L83UvYpy7MbCdHHU8mHiVg5pNhKzchWV+Fav+jUqEWmJ944thP134
+sd6MjsoOFuR8N7wm2OfVZMj4x8RLM2ryqUSIS65ulGWf55W3eA4KMnjtwhNHupoa
+QE76AxqRK6vGC7arGTGcPJ3tRaIlnaUq6GdiFtSPwerwgouEWjl7Ta+HOE88329Q
+4XIedEJs8UqBrzD2A5jToNBPrdJYTz3dEr8YmNmspICC50h+ytzVbP2gDaALmXru
+l8JSZoHeyctc6d4EeYJPH1NDkKi5Y8HTs5A5gZgFCfcFLV1D7LFSz/BzD7wciXT+
+xXmjs+GCOaw6bh1qf0tO3KENahwETbZ7DSV6o5ZPXGJwLoTZ01OpX74R9e2XnvLI
+Ykro1EFtukrAteqTMjPjuivnHeLMWhpnY0SDfH8vqbFHkVzs4GA8GWrLgxkc/Znu
+UvbP6yOXrfO0qPfqamu8/ukqE0KoPLjGXYtfSOBWXR9QHuO2RPHYJPF7qhQw1oZh
++g3W0r/Rummyk+AZwKlRkzEvMIGtHb7zWj2H8yYLf5nVeHOX8T4IFQzsOOl+AO10
+awamxYDKxNJcEt2DZoi6F6ip2Kik7ZY8PhhsyDqlU6gYTMizsdTKNXE5wEGtk1Iy
+FN3Xr3Ev3GpjleVTscKYxEEcj5z30lsBiEzocxvMciE6ZsVJhTbsrYvQnlHoz2mC
+YULpjtXraR+qcrjAMZC5hFNVcfz3CnFa8eJjPcA8qeIHtXdeODLSaCj04ivngDgu
+cG6TpKQf0eqNGhkp6ESlm3vK6eCn+QVMU1RV0TTZI3DgrebQ4ewbTTkRn/oHmkcy
+shrMdGeKuaiqpyHTphFhQcNHbVv6cv6kZAZ4UxqxRvIPyYDgRKjDArZuVG3umXh9
+8mJgbhW1EZZf6u3t6y1rVRYiTexnpyFyC4eHNzauuSxifTPgmi767A5r8dWqDJuV
+im/af1gGG3YnKYq1Md6HhGDyWIDbwAhamdyMWf+1U7kF1qhdy+XX6QGZVzt68jJS
+OY6CC9lFylD5jU1YE24us/fqHp3RlKcMqaNcaaeO5v1ZtbzCOlxq8U6xDdRtS3wK
+oIiTJxqoZX8ksxPzkjKFdP7yceC6Kzynhj9BB5BywMTXVU/9jnft7JuzqQmcOJlG
+x1qRJXV1uWq9AUcMUOUMSfcMkA2lH17rnlQQ99Y21iBOG589LhCWgxxDu1dTtBf4
+lORJwDZrAgyPrF+yi39Dp/EnXwcyVrYEuTNI8YfpXNc006bW8JY6xaRD32JH/Y2f
++x/CwYRrEaLs4+ayuUPsHPIYTqUx1ke/wkYYfLP+LWSVQYfZj3WtviGGLD3HDEh9
++XnPJlqKDz/itJtfvRL4PVbC9pv1Xyct+zu/SkMPWwIlI0MDnfMvdnSkF5WjWT/v
++tZzmDLzh8mx1NQQWCRZNt//AH7XtjPoXA/DO/rP1KvkIcf8Du1UpUauQSXx9pJo
+PJXKMPRPUqPHAreWMgN6RUy8cHKmRQjPvXR4XKITM6DapkX9JwOENYDQj1AQ0xeZ
+seOA/uMc33/AI65Lz/rChkcnocZ9j2RhNc4hwkMyUtC69zOhuYRu1QyMEQFa+Ggu
+ezxR5KJi
+-----END PUBLIC KEY-----
diff --git a/test/rubygems/pem_utilities.rb b/test/rubygems/pem_utilities.rb
index bec08d9a38dd..02459a0eef56 100644
--- a/test/rubygems/pem_utilities.rb
+++ b/test/rubygems/pem_utilities.rb
@@ -5,7 +5,7 @@
require "rubygems/openssl"
-module Gem::PemUtilities
+module Gem::PEMUtilities
##
# Loads certificate named +cert_name+ from test/rubygems/.
@@ -46,6 +46,20 @@ def self.load_key(key_name, passphrase = nil)
end
end
+ ##
+ # Loads a public key from the private key
+
+ def self.load_public_key(private_key)
+ return nil unless private_key
+
+ if private_key.respond_to?(:public_to_pem)
+ public_key = OpenSSL::PKey.read(private_key.public_to_pem)
+ else
+ public_key = private_key.public_key
+ end
+ public_key
+ end
+
##
# Returns the file path to the key named +key_name+ from test/rubygems
@@ -98,7 +112,7 @@ def self.pem_file(pem_name)
# ENCRYPTED_PRIVATE_KEY is PRIVATE_KEY encrypted with PRIVATE_KEY_PASSPHRASE
ENCRYPTED_PRIVATE_KEY = load_key "encrypted_private", PRIVATE_KEY_PASSPHRASE
ENCRYPTED_PRIVATE_KEY_FILE = key_file "encrypted_private"
- PUBLIC_KEY = PRIVATE_KEY.public_key
+ PUBLIC_KEY = load_public_key PRIVATE_KEY
PUBLIC_KEY_FILE = key_file "public"
PUBLIC_CERT = load_cert "public"
PUBLIC_CERT_FILE = cert_file "public"
@@ -117,6 +131,15 @@ def self.pem_file(pem_name)
EXPIRED_CERT_FILE = cert_file "expired"
FUTURE_CERT = load_cert "future"
WRONG_KEY_CERT = load_cert "wrong_key"
+ # ML-DSA-65
+ ML_DSA_65_PRIVATE_KEY = load_key "mldsa65_private"
+ ML_DSA_65_PRIVATE_KEY_FILE = key_file "mldsa65_private"
+ ML_DSA_65_ENCRYPTED_PRIVATE_KEY = load_key "mldsa65_encrypted_private", PRIVATE_KEY_PASSPHRASE
+ ML_DSA_65_ENCRYPTED_PRIVATE_KEY_FILE = key_file "mldsa65_encrypted_private"
+ ML_DSA_65_PUBLIC_KEY = load_public_key ML_DSA_65_PRIVATE_KEY
+ ML_DSA_65_PUBLIC_KEY_FILE = key_file "mldsa65_public"
+ ML_DSA_65_PUBLIC_CERT = load_cert "mldsa65_public"
+ ML_DSA_65_PUBLIC_CERT_FILE = cert_file "mldsa65_public"
# Keys and certificates manually created without script
# RSA 3072 bits
diff --git a/test/rubygems/pqc_utilities.rb b/test/rubygems/pqc_utilities.rb
new file mode 100644
index 000000000000..f9295f7f4849
--- /dev/null
+++ b/test/rubygems/pqc_utilities.rb
@@ -0,0 +1,80 @@
+# 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::PQCUtilities
+ CERTS_DIR = __dir__
+
+ # PQC algorithms ML-KEM and ML-DSA require OpenSSL >= 3.5.
+ # https://openssl-library.org/post/2025-04-08-openssl-35-final-release/
+ # Ruby OpenSSL >= 4.0 has useful methods in PQC use cases.
+ # https://github.com/ruby/openssl/blob/v4.0.0/History.md?plain=1#L25-L35
+ # And fixed the following bug related to PQC.
+ # https://github.com/ruby/openssl/pull/898
+ # However, we don't check OpenSSL and Ruby OpenSSL versions here
+ # for a flexible check for other SSL libraries such as LibreSSL and AWS-LC.
+ def without_pqc_support(&block)
+ # 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::PQCUtilities.support_pqc_handshake?
+ yield "OpenSSL or Ruby OpenSSL is too old to support PQC, "\
+ "or 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.key is nil when unsupported ML-DSA-65 algorithm's file is read with
+ # old OpenSSL versions.
+ return nil unless ctx.key
+
+ # ctx.groups (OpenSSL::SSL::SSLContext#groups) requires Ruby OpenSSL >= 4.0.
+ return nil unless ctx.respond_to?(:groups=)
+
+ 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
diff --git a/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb
index cd88421c0754..2c424aa0df6e 100644
--- a/test/rubygems/test_gem_commands_build_command.rb
+++ b/test/rubygems/test_gem_commands_build_command.rb
@@ -606,6 +606,39 @@ def test_build_signed_gem
assert gem.verify
end
+ def test_build_signed_gem_ml_dsa_65
+ pend "openssl is missing" unless Gem::HAVE_OPENSSL && !Gem.java_platform?
+
+ omit_unless_support_pqc
+
+ trust_dir = Gem::Security.trust_dir
+
+ spec = util_spec "some_gem" do |s|
+ s.signing_key = ML_DSA_65_PRIVATE_KEY_FILE
+ s.cert_chain = [ML_DSA_65_PUBLIC_CERT_FILE]
+ end
+
+ gemspec_file = File.join(@tempdir, spec.spec_name)
+
+ File.open gemspec_file, "w" do |gs|
+ gs.write spec.to_ruby
+ end
+
+ @cmd.options[:args] = [gemspec_file]
+
+ util_test_build_gem spec
+
+ trust_dir.trust_cert(
+ OpenSSL::X509::Certificate.new(
+ File.read(ML_DSA_65_PUBLIC_CERT_FILE)
+ )
+ )
+
+ gem = Gem::Package.new(File.join(@tempdir, spec.file_name),
+ Gem::Security::HighSecurity)
+ assert gem.verify
+ end
+
def test_build_signed_gem_with_cert_expiration_length_days
pend "openssl is missing" unless Gem::HAVE_OPENSSL && !Gem.java_platform?
diff --git a/test/rubygems/test_gem_commands_cert_command.rb b/test/rubygems/test_gem_commands_cert_command.rb
index 17fe3d789a6f..ae7385a05af2 100644
--- a/test/rubygems/test_gem_commands_cert_command.rb
+++ b/test/rubygems/test_gem_commands_cert_command.rb
@@ -157,6 +157,45 @@ def test_execute_build_key_algorithm_ec_key
assert cert.public_key.is_a? OpenSSL::PKey::EC
end
+ def test_execute_build_key_algorithm_ml_dsa_65_key
+ omit_unless_support_pqc
+
+ passphrase = "Foo bar"
+
+ @cmd.handle_options %W[--build nobody@example.com --key-algorithm ml-dsa-65]
+
+ @build_ui = Gem::MockGemUi.new "#{passphrase}\n#{passphrase}"
+
+ use_ui @build_ui do
+ @cmd.execute
+ end
+
+ output = @build_ui.output.squeeze("\n").split "\n"
+
+ assert_equal "Passphrase for your Private Key: ",
+ output.shift
+ assert_equal "Please repeat the passphrase for your Private Key: ",
+ output.shift
+ assert_equal "Certificate: #{File.join @tempdir, "gem-public_cert.pem"}",
+ output.shift
+ assert_equal "Private Key: #{File.join @tempdir, "gem-private_key.pem"}",
+ output.shift
+
+ assert_equal "Don't forget to move the key file to somewhere private!",
+ output.shift
+
+ assert_empty output
+ assert_empty @build_ui.error
+
+ assert_path_exist File.join(@tempdir, "gem-private_key.pem")
+
+ cert_path = File.join(@tempdir, "gem-public_cert.pem")
+ assert_path_exist cert_path
+ cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
+ assert cert.public_key.is_a? OpenSSL::PKey::PKey
+ assert_match(/type_name=ML-DSA-65/, cert.public_key.inspect)
+ end
+
def test_execute_build_bad_email_address
passphrase = "Foo bar"
email = "nobody@"
@@ -329,6 +368,53 @@ def test_execute_build_bad_key
assert_equal "incorrect signing key for signing", e.message
end
+ def test_execute_build_ml_dsa_65_key
+ omit_unless_support_pqc
+
+ @cmd.handle_options %W[
+ --build nobody@example.com
+ --private-key #{ML_DSA_65_PRIVATE_KEY_FILE}
+ ]
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ output = @ui.output.split "\n"
+
+ assert_equal "Certificate: #{File.join @tempdir, "gem-public_cert.pem"}",
+ output.shift
+
+ assert_empty output
+ assert_empty @ui.error
+
+ assert_path_exist File.join(@tempdir, "gem-public_cert.pem")
+ assert_path_not_exist File.join(@tempdir, "gem-private_key.pem")
+ end
+
+ def test_execute_build_encrypted_ml_dsa_65_key
+ omit_unless_support_pqc
+
+ @cmd.handle_options %W[
+ --build nobody@example.com
+ --private-key #{ML_DSA_65_ENCRYPTED_PRIVATE_KEY_FILE}
+ ]
+
+ use_ui @ui do
+ @cmd.execute
+ end
+
+ output = @ui.output.split "\n"
+
+ assert_equal "Certificate: #{File.join @tempdir, "gem-public_cert.pem"}",
+ output.shift
+
+ assert_empty output
+ assert_empty @ui.error
+
+ assert_path_exist File.join(@tempdir, "gem-public_cert.pem")
+ end
+
def test_execute_certificate
use_ui @ui do
@cmd.handle_options %W[--certificate #{PUBLIC_CERT_FILE}]
@@ -340,6 +426,19 @@ def test_execute_certificate
assert_equal PUBLIC_CERT.to_pem, @cmd.options[:issuer_cert].to_pem
end
+ def test_execute_certificate_ml_dsa_65
+ omit_unless_support_pqc
+
+ use_ui @ui do
+ @cmd.handle_options %W[--certificate #{ML_DSA_65_PUBLIC_CERT_FILE}]
+ end
+
+ assert_equal "", @ui.output
+ assert_equal "", @ui.error
+
+ assert_equal ML_DSA_65_PUBLIC_CERT.to_pem, @cmd.options[:issuer_cert].to_pem
+ end
+
def test_execute_list
@trust_dir.trust_cert PUBLIC_CERT
@trust_dir.trust_cert ALTERNATE_CERT
@@ -391,6 +490,35 @@ def test_execute_encrypted_private_key
assert_equal ENCRYPTED_PRIVATE_KEY.private_to_pem, @cmd.options[:key].private_to_pem
end
+ def test_execute_private_ml_dsa_65_key
+ omit_unless_support_pqc
+
+ use_ui @ui do
+ @cmd.send :handle_options, %W[--private-key #{ML_DSA_65_PRIVATE_KEY_FILE}]
+ end
+
+ assert_equal "", @ui.output
+ assert_equal "", @ui.error
+
+ assert_equal ML_DSA_65_PRIVATE_KEY.private_to_pem,
+ @cmd.options[:key].private_to_pem
+ end
+
+ def test_execute_encrypted_private_ml_dsa_65_key
+ omit_unless_support_pqc
+
+ use_ui @ui do
+ @cmd.send :handle_options,
+ %W[--private-key #{ML_DSA_65_ENCRYPTED_PRIVATE_KEY_FILE}]
+ end
+
+ assert_equal "", @ui.output
+ assert_equal "", @ui.error
+
+ assert_equal ML_DSA_65_ENCRYPTED_PRIVATE_KEY.private_to_pem,
+ @cmd.options[:key].private_to_pem
+ end
+
def test_execute_remove
@trust_dir.trust_cert PUBLIC_CERT
@@ -811,7 +939,9 @@ def test_handle_options_key_bad
@cmd.handle_options %W[--private-key #{bad}]
end
- assert_equal "invalid argument: --private-key #{bad}: invalid RSA, DSA, or EC key",
+ assert_equal "invalid argument: "\
+ "--private-key #{bad}: "\
+ "invalid RSA, DSA, EC, ML-DSA-44, ML-DSA-65, or ML-DSA-87 key",
e.message
end
diff --git a/test/rubygems/test_gem_remote_fetcher_local_ssl_server.rb b/test/rubygems/test_gem_remote_fetcher_local_ssl_server.rb
index 813e8e2f0265..144d80c134b0 100644
--- a/test/rubygems/test_gem_remote_fetcher_local_ssl_server.rb
+++ b/test/rubygems/test_gem_remote_fetcher_local_ssl_server.rb
@@ -155,10 +155,4 @@ def with_configured_fetcher(config_str = nil, &block)
fetcher.close_all
Gem.configuration = nil
end
-
- def omit_unless_support_pqc
- without_pqc_support do |message|
- omit message
- end
- end
end if Gem::HAVE_OPENSSL
diff --git a/test/rubygems/test_gem_security.rb b/test/rubygems/test_gem_security.rb
index 20f4b9fa8951..114cbaa7ee5a 100644
--- a/test/rubygems/test_gem_security.rb
+++ b/test/rubygems/test_gem_security.rb
@@ -99,15 +99,68 @@ def test_class_create_key_downcases
assert_kind_of OpenSSL::PKey::DSA, key
end
+ def test_class_create_key_ec
+ key = Gem::Security.create_key "ec"
+
+ assert_kind_of OpenSSL::PKey::EC, key
+ end
+
+ def test_class_create_key_ml_dsa_44
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-44"
+
+ assert_kind_of OpenSSL::PKey::PKey, key
+ assert_match(/type_name=ML-DSA-44/, key.inspect)
+ end
+
+ def test_class_create_key_ml_dsa_65
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-65"
+
+ assert_kind_of OpenSSL::PKey::PKey, key
+ assert_match(/type_name=ML-DSA-65/, key.inspect)
+ end
+
+ def test_class_create_key_ml_dsa_87
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-87"
+
+ assert_kind_of OpenSSL::PKey::PKey, key
+ assert_match(/type_name=ML-DSA-87/, key.inspect)
+ end
+
def test_class_create_key_raises_unknown_algorithm
e = assert_raise Gem::Security::Exception do
Gem::Security.create_key "NOT_RSA"
end
- assert_equal "NOT_RSA algorithm not found. RSA, DSA, and EC algorithms are supported.",
+ assert_equal "NOT_RSA algorithm not found. RSA, DSA, EC, "\
+ "ML-DSA-44, ML-DSA-65, and ML-DSA-87 "\
+ "algorithms are supported.",
e.message
end
+ def test_class_digest_required_rsa
+ assert Gem::Security.digest_required?(Gem::Security.create_key("rsa"))
+ end
+
+ def test_class_digest_required_dsa
+ assert Gem::Security.digest_required?(Gem::Security.create_key("dsa"))
+ end
+
+ def test_class_digest_required_ec
+ assert Gem::Security.digest_required?(Gem::Security.create_key("ec"))
+ end
+
+ def test_class_digest_required_ml_dsa_65
+ omit_unless_support_pqc
+
+ refute Gem::Security.digest_required?(Gem::Security.create_key("ml-dsa-65"))
+ end
+
def test_class_get_public_key_rsa
pkey_pem = PRIVATE_KEY.public_key.public_to_pem
@@ -120,6 +173,14 @@ def test_class_get_public_key_ec
assert_respond_to pkey, :public_to_pem
end
+ def test_class_get_public_key_ml_dsa_65
+ omit_unless_support_pqc
+
+ pkey = Gem::Security.get_public_key(ML_DSA_65_PRIVATE_KEY)
+
+ assert_respond_to pkey, :public_to_pem
+ end
+
def test_class_email_to_name
assert_equal "/CN=nobody/DC=example",
Gem::Security.email_to_name("nobody@example").to_s
@@ -179,43 +240,7 @@ def test_class_reset
end
def test_class_sign
- issuer = PUBLIC_CERT.subject
- signee = OpenSSL::X509::Name.new([["CN", "signee"], ["DC", "example"]])
-
- key = PRIVATE_KEY
- cert = OpenSSL::X509::Certificate.new
- cert.subject = signee
-
- cert.subject = signee
- cert.public_key = key.public_key
-
- signed = Gem::Security.sign cert, key, PUBLIC_CERT, 60
-
- assert_equal key.public_key.public_to_pem, signed.public_key.public_to_pem
- assert_equal signee.to_s, signed.subject.to_s
- assert_equal issuer.to_s, signed.issuer.to_s
-
- assert_in_delta Time.now, signed.not_before, 10
- assert_in_delta Time.now + 60, signed.not_after, 10
-
- assert_equal 4, signed.extensions.length,
- signed.extensions.map {|e| e.to_a.first }
-
- constraints = signed.extensions.find {|ext| ext.oid == "issuerAltName" }
- assert_equal "email:nobody@example", constraints.value, "issuerAltName"
-
- constraints = signed.extensions.find {|ext| ext.oid == "basicConstraints" }
- assert_equal "CA:FALSE", constraints.value
-
- key_usage = signed.extensions.find {|ext| ext.oid == "keyUsage" }
- assert_equal "Digital Signature, Key Encipherment, Data Encipherment",
- key_usage.value
-
- key_ident =
- signed.extensions.find {|ext| ext.oid == "subjectKeyIdentifier" }
- assert_equal 59, key_ident.value.length
-
- assert signed.verify key
+ assert_sign PUBLIC_CERT, PRIVATE_KEY
end
def test_class_sign_AltName
@@ -258,6 +283,12 @@ def test_class_sign_AltName
assert signed.verify PUBLIC_KEY
end
+ def test_class_sign_ml_dsa_65
+ omit_unless_support_pqc
+
+ assert_sign ML_DSA_65_PUBLIC_CERT, ML_DSA_65_PRIVATE_KEY
+ end
+
def test_class_trust_dir
trust_dir = Gem::Security.trust_dir
@@ -280,6 +311,22 @@ def test_class_write_private_key
assert_equal key.private_to_pem, key_from_file
end
+ def test_class_write_private_key_ml_dsa_65
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-65"
+
+ path = File.join @tempdir, "test-ml-dsa-private_key.pem"
+
+ Gem::Security.write_private_key key, path
+
+ assert_path_exist path
+
+ key_from_file = File.read path
+
+ assert_equal key.private_to_pem, key_from_file
+ end
+
def test_class_write_private_key_encrypted
key = Gem::Security.create_key "rsa"
@@ -296,6 +343,24 @@ def test_class_write_private_key_encrypted
assert_equal key.private_to_pem, key_from_file.private_to_pem
end
+ def test_class_write_private_key_encrypted_ml_dsa_65
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-65"
+
+ path = File.join @tempdir, "test-ml-dsa-private_encrypted_key.pem"
+
+ passphrase = "It should be long."
+
+ Gem::Security.write_private_key key, path, 0o600, passphrase
+
+ assert_path_exist path
+
+ key_from_file = OpenSSL::PKey.read File.read(path), passphrase
+
+ assert_equal key.private_to_pem, key_from_file.private_to_pem
+ end
+
def test_class_write_private_key_encrypted_cipher
key = Gem::Security.create_key "rsa"
@@ -317,6 +382,31 @@ def test_class_write_private_key_encrypted_cipher
assert_equal key.private_to_pem, key_from_file.private_to_pem
end
+ def test_class_write_private_key_encrypted_cipher_ml_dsa_65
+ omit_unless_support_pqc
+
+ key = Gem::Security.create_key "ml-dsa-65"
+
+ path = File.join @tempdir,
+ "test-ml-dsa-private_encrypted_with_non_default_cipher_key.pem"
+
+ passphrase = "It should be long."
+
+ cipher = OpenSSL::Cipher.new "AES-192-CBC"
+
+ Gem::Security.write_private_key key, path, 0o600, passphrase, cipher
+
+ assert_path_exist path
+
+ # Unlike RSA's traditional PEM format where the cipher name appears in a
+ # plaintext DEK-Info header, ML-DSA uses private_to_pem which outputs PKCS#8
+ # format where there is no header, and the cipher name doesn't appear as a
+ # text. So we cannot assert the cipher name in the PEM text.
+ key_from_file = OpenSSL::PKey.read File.read(path), passphrase
+
+ assert_equal key.private_to_pem, key_from_file.private_to_pem
+ end
+
def test_class_write_certificate
path = File.join @tempdir, "test-public_cert.pem"
@@ -328,4 +418,46 @@ def test_class_write_certificate
assert_equal PUBLIC_CERT.to_pem, cert_from_file
end
+
+ private
+
+ def assert_sign(signing_cert, signing_key)
+ issuer = signing_cert.subject
+ signee = OpenSSL::X509::Name.new([["CN", "signee"], ["DC", "example"]])
+
+ key = signing_key
+ cert = OpenSSL::X509::Certificate.new
+ cert.subject = signee
+ public_key = Gem::Security.get_public_key(key)
+ cert.public_key = public_key
+
+ signed = Gem::Security.sign cert, key, signing_cert, 60
+ signed_public_key = Gem::Security.get_public_key(signed)
+
+ assert_equal public_key.public_to_pem, signed_public_key.public_to_pem
+ assert_equal signee.to_s, signed.subject.to_s
+ assert_equal issuer.to_s, signed.issuer.to_s
+
+ assert_in_delta Time.now, signed.not_before, 10
+ assert_in_delta Time.now + 60, signed.not_after, 10
+
+ assert_equal 4, signed.extensions.length,
+ signed.extensions.map {|e| e.to_a.first }
+
+ constraints = signed.extensions.find {|ext| ext.oid == "issuerAltName" }
+ assert_equal "email:nobody@example", constraints.value, "issuerAltName"
+
+ constraints = signed.extensions.find {|ext| ext.oid == "basicConstraints" }
+ assert_equal "CA:FALSE", constraints.value
+
+ key_usage = signed.extensions.find {|ext| ext.oid == "keyUsage" }
+ assert_equal "Digital Signature, Key Encipherment, Data Encipherment",
+ key_usage.value
+
+ key_ident =
+ signed.extensions.find {|ext| ext.oid == "subjectKeyIdentifier" }
+ assert_equal 59, key_ident.value.length
+
+ assert signed.verify key
+ end
end if Gem::HAVE_OPENSSL && !Gem.java_platform?
diff --git a/test/rubygems/test_gem_security_policy.rb b/test/rubygems/test_gem_security_policy.rb
index 30116ae9ef42..6925e77ebcdc 100644
--- a/test/rubygems/test_gem_security_policy.rb
+++ b/test/rubygems/test_gem_security_policy.rb
@@ -53,6 +53,16 @@ def test_check_data
assert @almost_no.check_data(PUBLIC_KEY, @digest, signature, data)
end
+ def test_check_data_ml_dsa_65
+ omit_unless_support_pqc
+
+ data = digest "hello"
+
+ signature = sign data, ML_DSA_65_PRIVATE_KEY, nil
+
+ assert @almost_no.check_data(ML_DSA_65_PUBLIC_KEY, @digest, signature, data)
+ end
+
def test_check_data_invalid
data = digest "hello"
@@ -67,6 +77,22 @@ def test_check_data_invalid
assert_equal "invalid signature", e.message
end
+ def test_check_data_invalid_ml_dsa_65
+ omit_unless_support_pqc
+
+ data = digest "hello"
+
+ signature = sign data, ML_DSA_65_PRIVATE_KEY, nil
+
+ invalid = digest "hello!"
+
+ e = assert_raise Gem::Security::Exception do
+ @almost_no.check_data ML_DSA_65_PUBLIC_KEY, @digest, signature, invalid
+ end
+
+ assert_equal "invalid signature", e.message
+ end
+
def test_check_chain
chain = [PUBLIC_CERT, CHILD_CERT, GRANDCHILD_CERT]
@@ -222,6 +248,14 @@ def test_check_trust
assert @high.check_trust [PUBLIC_CERT], @digest, @trust_dir
end
+ def test_check_trust_ml_dsa_65
+ omit_unless_support_pqc
+
+ Gem::Security.trust_dir.trust_cert ML_DSA_65_PUBLIC_CERT
+
+ assert @high.check_trust [ML_DSA_65_PUBLIC_CERT], @digest, @trust_dir
+ end
+
def test_check_trust_child
Gem::Security.trust_dir.trust_cert PUBLIC_CERT
@@ -509,8 +543,8 @@ def digest(data)
digester
end
- def sign(data, key = PRIVATE_KEY)
- key.sign @digest.new, data.digest
+ def sign(data, key = PRIVATE_KEY, digest = @digest.new)
+ key.sign digest, data.digest
end
def dummy_signatures(key = PRIVATE_KEY)
diff --git a/test/rubygems/test_gem_security_signer.rb b/test/rubygems/test_gem_security_signer.rb
index 59a379322b63..c3494f478a31 100644
--- a/test/rubygems/test_gem_security_signer.rb
+++ b/test/rubygems/test_gem_security_signer.rb
@@ -69,6 +69,16 @@ def test_initialize_key_path
assert_equal PRIVATE_KEY.to_s, signer.key.to_s
end
+ def test_initialize_key_path_ml_dsa_65
+ omit_unless_support_pqc
+
+ key_file = ML_DSA_65_PRIVATE_KEY_FILE
+
+ signer = Gem::Security::Signer.new key_file, nil
+
+ assert_equal ML_DSA_65_PRIVATE_KEY.private_to_pem, signer.key.private_to_pem
+ end
+
def test_initialize_encrypted_key_path
key_file = ENCRYPTED_PRIVATE_KEY_FILE
@@ -77,6 +87,17 @@ def test_initialize_encrypted_key_path
assert_equal ENCRYPTED_PRIVATE_KEY.to_s, signer.key.to_s
end
+ def test_initialize_encrypted_key_path_ml_dsa_65
+ omit_unless_support_pqc
+
+ key_file = ML_DSA_65_ENCRYPTED_PRIVATE_KEY_FILE
+
+ signer = Gem::Security::Signer.new key_file, nil, PRIVATE_KEY_PASSPHRASE
+
+ assert_equal ML_DSA_65_ENCRYPTED_PRIVATE_KEY.private_to_pem,
+ signer.key.private_to_pem
+ end
+
def test_extract_name
signer = Gem::Security::Signer.new nil, nil
@@ -116,6 +137,16 @@ def test_sign
assert PRIVATE_KEY.verify(digest, signature, "hello")
end
+ def test_sign_ml_dsa_65
+ omit_unless_support_pqc
+
+ signer = Gem::Security::Signer.new ML_DSA_65_PRIVATE_KEY, [ML_DSA_65_PUBLIC_CERT]
+
+ signature = signer.sign "hello"
+
+ assert ML_DSA_65_PRIVATE_KEY.verify(nil, signature, "hello")
+ end
+
def test_sign_expired
signer = Gem::Security::Signer.new PRIVATE_KEY, [EXPIRED_CERT]
diff --git a/tool/create_certs.rb b/tool/create_certs.rb
index 1a13950a5f74..f09e6ecfb453 100644
--- a/tool/create_certs.rb
+++ b/tool/create_certs.rb
@@ -5,15 +5,69 @@
class CertificateBuilder
attr_reader :start
- def initialize(key_size = 2048)
+ BASE_DIR = "test/rubygems"
+
+ def initialize(prefix: "")
+ @prefix = prefix
@start = Time.utc 2012, 1, 1, 0, 0, 0
@end_of_time = Time.utc 9999, 12, 31, 23, 59, 59
@end_of_time_32 = Time.utc 2038, 1, 19, 3, 14, 7
- @key_size = key_size
@serial = 0
end
+ def execute
+ keys, certs = create_keys_and_certificates
+ write_keys_and_certificates(keys, certs)
+ end
+
+ def create_keys_and_certificates
+ keys = create_keys [
+ :alternate,
+ :child,
+ :grandchild,
+ :invalid,
+ :invalidchild,
+ :private,
+ ]
+
+ certs = {}
+ certs[:public] =
+ create_certificates(keys[:private], "nobody",
+ is_ca: true)
+ certs[:child] =
+ create_certificates(keys[:child], "child",
+ keys[:private], certs[:public],
+ is_ca: true)
+ certs[:alternate] =
+ create_certificates(keys[:alternate], "alternate")
+ certs[:expired] =
+ create_certificates(keys[:private], "nobody",
+ not_before: Time.at(0),
+ not_after: Time.at(0))
+ certs[:future] =
+ create_certificates(keys[:private], "nobody",
+ not_before: :end_of_time,
+ not_after: :end_of_time)
+ certs[:invalid_issuer] =
+ create_certificates(keys[:invalid], "invalid",
+ keys[:invalid], certs[:public],
+ is_ca: true)
+ certs[:grandchild] =
+ create_certificates(keys[:grandchild], "grandchild",
+ keys[:child], certs[:child])
+ certs[:invalid_signer] =
+ create_certificates(keys[:invalid], "invalid",
+ keys[:private], certs[:invalid])
+ certs[:invalidchild] =
+ create_certificates(keys[:invalidchild], "invalidchild",
+ keys[:invalid], certs[:child])
+ certs[:wrong_key] =
+ create_certificates(keys[:alternate], "nobody")
+
+ [keys, certs]
+ end
+
def create_certificates(key, subject, issuer_key = key, issuer_cert = nil,
not_before: @start, not_after: :end_of_time, is_ca: false)
certificates = []
@@ -25,9 +79,16 @@ def create_certificates(key, subject, issuer_key = key, issuer_cert = nil,
certificates <<
create_certificate(key, subject, issuer_key, issuer_cert,
not_before, not_after, is_ca)
- certificates <<
- create_certificate(key, subject, issuer_key, issuer_cert_32,
- not_before_32, not_after_32, is_ca)
+
+ # When not_before and not_after are the same between the regular and 32-bit
+ # certificates, don't create 32-bit certificate. Because it is redundant.
+ if not_before == not_before_32 && not_after == not_after_32
+ certificates << nil
+ else
+ certificates <<
+ create_certificate(key, subject, issuer_key, issuer_cert_32,
+ not_before_32, not_after_32, is_ca)
+ end
certificates
end
@@ -45,7 +106,7 @@ def create_certificate(key, subject, issuer_key, issuer_cert,
cert.serial = next_serial
- cert.public_key = key.public_key
+ cert.public_key = OpenSSL::PKey.read(key.public_to_pem)
cert.subject = OpenSSL::X509::Name.new [%W[CN #{subject}], %w[DC example]]
cert.issuer = issuer_cert.subject
@@ -66,17 +127,12 @@ def create_certificate(key, subject, issuer_key, issuer_cert,
cert.add_extension ef.create_extension("keyUsage", "keyCertSign", true)
end
- cert.sign issuer_key, "SHA256"
+ cert.sign issuer_key, digest_name
puts "created cert - subject: #{cert.subject}, issuer: #{cert.issuer}"
cert
end
- def create_key
- puts "creating key"
- OpenSSL::PKey::RSA.new @key_size
- end
-
def create_keys(names)
keys = {}
@@ -103,72 +159,87 @@ def validity_for(time)
[validity, validity_32]
end
+
+ def write_keys_and_certificates(keys, certs)
+ cipher = "aes-256-cbc"
+ passphrase = "Foo bar"
+
+ keys.each do |name, key|
+ dest = File.join BASE_DIR, "#{@prefix}#{name}_key.pem"
+ File.write dest, key.private_to_pem
+
+ next unless name == :private
+
+ # Write the public key PEM from the private key
+ dest = File.join BASE_DIR, "#{@prefix}public_key.pem"
+ File.write dest, key.public_to_pem
+
+ # Create an encrypted private key protected by a passphrase
+ # it has to be the same as is in test/rubygems/helper.rb in PRIVATE_KEY_PASSPHRASE
+ dest = File.join BASE_DIR, "#{@prefix}encrypted_#{name}_key.pem"
+ File.write dest, key.private_to_pem(cipher, passphrase)
+ end
+
+ certs.each do |name, (cert, cert_32)|
+ dest = File.join BASE_DIR, "#{@prefix}#{name}_cert.pem"
+ File.write dest, cert.to_pem
+
+ next unless cert_32
+
+ dest = File.join BASE_DIR, "#{@prefix}#{name}_cert_32.pem"
+ File.write dest, cert_32.to_pem
+ end
+ end
end
-cb = CertificateBuilder.new
-
-keys = cb.create_keys [
- :alternate,
- :child,
- :grandchild,
- :invalid,
- :invalidchild,
- :private,
-]
-
-keys[:public] = keys[:private].public_key
-
-certs = {}
-certs[:public] =
- cb.create_certificates(keys[:private], "nobody",
- is_ca: true)
-certs[:child] =
- cb.create_certificates(keys[:child], "child",
- keys[:private], certs[:public],
- is_ca: true)
-certs[:alternate] =
- cb.create_certificates(keys[:alternate], "alternate")
-certs[:expired] =
- cb.create_certificates(keys[:private], "nobody",
- not_before: Time.at(0),
- not_after: Time.at(0))
-certs[:future] =
- cb.create_certificates(keys[:private], "nobody",
- not_before: :end_of_time,
- not_after: :end_of_time)
-certs[:invalid_issuer] =
- cb.create_certificates(keys[:invalid], "invalid",
- keys[:invalid], certs[:public],
- is_ca: true)
-certs[:grandchild] =
- cb.create_certificates(keys[:grandchild], "grandchild",
- keys[:child], certs[:child])
-certs[:invalid_signer] =
- cb.create_certificates(keys[:invalid], "invalid",
- keys[:private], certs[:invalid])
-certs[:invalidchild] =
- cb.create_certificates(keys[:invalidchild], "invalidchild",
- keys[:invalid], certs[:child])
-certs[:wrong_key] =
- cb.create_certificates(keys[:alternate], "nobody")
-
-base_dir = "test/rubygems"
-
-keys.each do |name, key|
- dest = File.join base_dir, "#{name}_key.pem"
- pem = name == :public ? key.public_to_pem : key.private_to_pem
- File.write dest, pem
+class RSACertificateBuilder < CertificateBuilder
+ def initialize(key_size: 2048)
+ super(prefix: "")
+ @key_size = key_size
+ end
+
+ def create_key
+ puts "creating RSA key"
+ OpenSSL::PKey::RSA.new @key_size
+ end
+
+ def digest_name
+ "SHA256"
+ end
end
-# Create an encrypted private key protected by a passphrase from the new keys[:private]
-# it has to be the same as is in # test/rubygems/helper.rb in PRIVATE_KEY_PASSPHRASE
-dest = File.join base_dir, "encrypted_private_key.pem"
-File.write dest, keys[:private].private_to_pem(OpenSSL::Cipher.new("aes-256-cbc"), "Foo bar")
+class MLDSA65CertificateBuilder < CertificateBuilder
+ def initialize
+ super(prefix: "mldsa65_")
+ end
+
+ # Only create the private key and public cert needed for current tests.
+ def create_keys_and_certificates
+ keys = create_keys [
+ :private,
+ ]
+
+ certs = {}
+ certs[:public] =
+ create_certificates(keys[:private], "nobody",
+ is_ca: true)
-certs.each do |name, (cert, cert_32)|
- dest = File.join base_dir, "#{name}_cert.pem"
- File.write dest, cert.to_pem
+ [keys, certs]
+ end
- dest = File.join base_dir, "#{name}_cert_32.pem"
- File.write dest, cert_32.to_pem
+ def create_key
+ puts "creating ML-DSA-65 key"
+ OpenSSL::PKey.generate_key("ML-DSA-65")
+ end
+
+ # ML-DSA keys do not support explicit digest algorithms.
+ def digest_name
+ nil
+ end
end
+
+# Create RSA and ML-DSA-65 certificates
+[
+ RSACertificateBuilder.new,
+ MLDSA65CertificateBuilder.new,
+].each(&:execute)
diff --git a/tool/create_encrypted_key.rb b/tool/create_encrypted_key.rb
deleted file mode 100644
index 3f7f7f500a22..000000000000
--- a/tool/create_encrypted_key.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-# frozen_string_literal: true
-
-require "openssl"
-
-test_path = File.expand_path("../test/rubygems", __dir__)
-
-private_key_path = "#{test_path}/private_key.pem"
-
-key = OpenSSL::PKey::RSA.new File.read private_key_path
-
-cipher = OpenSSL::Cipher.new "AES-256-CBC"
-
-encrypted_key_path = "#{test_path}/encrypted_private_key.pem"
-
-File.open encrypted_key_path, "w" do |io|
- io.write key.private_to_pem cipher, "Foo bar"
-end