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..9dbae3d4522b
--- /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..686c961c231a
--- /dev/null
+++ b/test/rubygems/mldsa65_private_key.pem
@@ -0,0 +1,88 @@
+-----BEGIN PRIVATE KEY-----
+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
+K217eS//q+3kNLi5FMCDRxX1
+-----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..510323d3d590
--- /dev/null
+++ b/test/rubygems/mldsa65_public_cert.pem
@@ -0,0 +1,118 @@
+-----BEGIN CERTIFICATE-----
+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
+eL8AAAAAAAAAAAAAAAAAAAAAAAAABwwRGR4k
+-----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..ecf7f7e6ac56
--- /dev/null
+++ b/test/rubygems/mldsa65_public_cert_32.pem
@@ -0,0 +1,118 @@
+-----BEGIN CERTIFICATE-----
+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
+n7/nAAAAAAAAAAAAAAAAAAAAAAYNFRshJw==
+-----END CERTIFICATE-----
diff --git a/test/rubygems/mldsa65_public_key.pem b/test/rubygems/mldsa65_public_key.pem
new file mode 100644
index 000000000000..61fbbb8e8b54
--- /dev/null
+++ b/test/rubygems/mldsa65_public_key.pem
@@ -0,0 +1,44 @@
+-----BEGIN PUBLIC KEY-----
+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
+5T5qADCQ
+-----END PUBLIC KEY-----
diff --git a/test/rubygems/pem_utilities.rb b/test/rubygems/pem_utilities.rb
index bec08d9a38dd..7c69c375b381 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/.
@@ -33,7 +33,8 @@ def self.cert_file(cert_name)
end
##
- # Loads a private key named +key_name+ with +passphrase+ in test/rubygems/
+ # Loads a private key named +key_name+ with +passphrase+ or a public key
+ # named +key_name+ in test/rubygems/
def self.load_key(key_name, passphrase = nil)
key = File.read(key_file(key_name))
@@ -98,7 +99,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_key "public"
PUBLIC_KEY_FILE = key_file "public"
PUBLIC_CERT = load_cert "public"
PUBLIC_CERT_FILE = cert_file "public"
@@ -117,6 +118,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_key "mldsa65_public"
+ 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