From c998897ffe27414e0aea4b1d6a4771b9b96f2a1a Mon Sep 17 00:00:00 2001 From: Justin W Smith Date: Thu, 3 Sep 2026 17:20:31 +0000 Subject: [PATCH 1/2] Identify ML-DSA by OID and gate ML-DSA tests on the capability they need The ML-DSA support checks assume an SSL library either supports ML-DSA completely or not at all, and identify ML-DSA keys by the name OpenSSL 3.5 registers. Both break on libraries that implement ML-DSA differently: AWS-LC loads ML-DSA keys and certificates and signs and verifies with them, but has no keygen by algorithm name, and names OID 2.16.840.1.101.3.4.3.18 "MLDSA65". ml_dsa_key? matches ML_DSA_NAMES against ObjectId#ln, so it fails to recognise a valid ML-DSA key when the library spells the name differently, and digest_required? then raises "unsupported key algorithm" for a key RubyGems does support, making ML-DSA signed gems unusable there. Match the SubjectPublicKeyInfo OID instead, which is identical on every library. ML_DSA_NAMES becomes unused and is removed; the individual name constants stay, since create_key still passes them to OpenSSL::PKey.generate_key. support_ml_dsa_key? probes key generation but gates tests that only need loading. Four tests assert the "no ML-DSA support" path for a key or certificate read from disk, and run on a false premise where loading works but generation does not: two raise nothing and two raise a different error. Ten more use only the checked-in ML-DSA fixtures, yet are skipped for want of a capability they never exercise. Add support_ml_dsa_key_load? and gate those fourteen on it. test_gem_security.rb is unchanged, since its ML-DSA tests build keys with Gem::Security.create_key and do need the generation gate. Continues 9a4353546d, which separated generation from nil-digest certificate signing but not generation from loading. --- lib/rubygems/security.rb | 15 +++++++++--- test/rubygems/helper.rb | 10 ++++++++ test/rubygems/pqc_utilities.rb | 23 +++++++++++++++++++ .../test_gem_commands_build_command.rb | 4 ++-- .../test_gem_commands_cert_command.rb | 8 +++---- test/rubygems/test_gem_security_policy.rb | 8 +++---- test/rubygems/test_gem_security_signer.rb | 8 +++---- 7 files changed, 59 insertions(+), 17 deletions(-) diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb index 1d86ecc909cc..999f7bb9a06c 100644 --- a/lib/rubygems/security.rb +++ b/lib/rubygems/security.rb @@ -367,8 +367,17 @@ class Exception < Gem::Exception; end 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 + # ML-DSA SubjectPublicKeyInfo algorithm OIDs (NIST FIPS 204). Matching the OID + # rather than the algorithm name keeps this working on SSL libraries that + # spell the name differently: AWS-LC registers 2.16.840.1.101.3.4.3.18 as + # "MLDSA65", not "ML-DSA-65". + + ML_DSA_OIDS = %w[ + 2.16.840.1.101.3.4.3.17 + 2.16.840.1.101.3.4.3.18 + 2.16.840.1.101.3.4.3.19 + ].freeze + private_constant :ML_DSA_OIDS ## # Cipher used to encrypt the key pair used to sign gems. @@ -527,7 +536,7 @@ def self.create_ml_dsa_key(algorithm) def self.ml_dsa_key?(key) algorithm = OpenSSL::ASN1.decode(key.public_to_der).value.first.value.first - ML_DSA_NAMES.include?(algorithm.ln) + ML_DSA_OIDS.include?(algorithm.oid) rescue OpenSSL::ASN1::ASN1Error, OpenSSL::PKey::PKeyError, NoMethodError false end diff --git a/test/rubygems/helper.rb b/test/rubygems/helper.rb index cd50ed5ae1a8..a1466921b6af 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1697,6 +1697,11 @@ def omit_unless_support_ml_dsa_key Gem::PQCUtilities.support_ml_dsa_key? end + def omit_unless_support_ml_dsa_key_load + omit "OpenSSL cannot load ML-DSA keys" unless + Gem::PQCUtilities.support_ml_dsa_key_load? + 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? @@ -1710,6 +1715,11 @@ def omit_if_support_ml_dsa_cert def omit_if_support_ml_dsa_key omit "OpenSSL supports ML-DSA" if Gem::PQCUtilities.support_ml_dsa_key? end + + def omit_if_support_ml_dsa_key_load + omit "OpenSSL loads ML-DSA keys" if + Gem::PQCUtilities.support_ml_dsa_key_load? + end end # https://github.com/seattlerb/minitest/blob/13c48a03d84a2a87855a4de0c959f96800100357/lib/minitest/mock.rb#L192 diff --git a/test/rubygems/pqc_utilities.rb b/test/rubygems/pqc_utilities.rb index 3b46daa93766..0eb6ccf73d77 100644 --- a/test/rubygems/pqc_utilities.rb +++ b/test/rubygems/pqc_utilities.rb @@ -52,6 +52,29 @@ def self.support_ml_dsa_key? end end + ## + # Returns whether the runtime OpenSSL can load an ML-DSA key. A library can + # read ML-DSA keys without being able to generate them: AWS-LC parses ML-DSA + # keys and certificates, and signs and verifies with them, but registers no + # keygen by algorithm name, so support_ml_dsa_key? is false there. Tests that + # assert the "no ML-DSA support" path for a key or certificate read from disk + # need this instead of support_ml_dsa_key?. + + def self.support_ml_dsa_key_load? + return @support_ml_dsa_key_load unless @support_ml_dsa_key_load.nil? + + @support_ml_dsa_key_load = + begin + !OpenSSL::PKey.read( + File.read(File.join(CERTS_DIR, "mldsa65_private_key.pem")) + ).nil? + # Mirrors Gem::PEMUtilities.load_key, which rescues the same error when an + # unsupported key algorithm is read. + rescue OpenSSL::PKey::PKeyError + false + end + end + ## # Returns whether the runtime can sign an X.509 certificate with an ML-DSA # key. Ruby OpenSSL rejects the nil digest that needs before 3.3, so diff --git a/test/rubygems/test_gem_commands_build_command.rb b/test/rubygems/test_gem_commands_build_command.rb index 03af9dfff228..1adb52170d44 100644 --- a/test/rubygems/test_gem_commands_build_command.rb +++ b/test/rubygems/test_gem_commands_build_command.rb @@ -609,7 +609,7 @@ def test_build_signed_gem def test_build_signed_gem_ml_dsa_65 pend "openssl is missing" unless Gem::HAVE_OPENSSL && !Gem.java_platform? - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load trust_dir = Gem::Security.trust_dir @@ -642,7 +642,7 @@ def test_build_signed_gem_ml_dsa_65 def test_build_signed_gem_ml_dsa_65_without_ml_dsa_support pend "openssl is missing" unless Gem::HAVE_OPENSSL - omit_if_support_ml_dsa_key + omit_if_support_ml_dsa_key_load spec = util_spec "some_gem" do |s| s.signing_key = ML_DSA_65_PRIVATE_KEY_FILE diff --git a/test/rubygems/test_gem_commands_cert_command.rb b/test/rubygems/test_gem_commands_cert_command.rb index e8063f76ac3c..d678c9778580 100644 --- a/test/rubygems/test_gem_commands_cert_command.rb +++ b/test/rubygems/test_gem_commands_cert_command.rb @@ -449,7 +449,7 @@ def test_execute_certificate end def test_execute_certificate_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load use_ui @ui do @cmd.handle_options %W[--certificate #{ML_DSA_65_PUBLIC_CERT_FILE}] @@ -513,7 +513,7 @@ def test_execute_encrypted_private_key end def test_execute_private_ml_dsa_65_key - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load use_ui @ui do @cmd.send :handle_options, %W[--private-key #{ML_DSA_65_PRIVATE_KEY_FILE}] @@ -527,7 +527,7 @@ def test_execute_private_ml_dsa_65_key end def test_execute_private_ml_dsa_65_key_without_ml_dsa_support - omit_if_support_ml_dsa_key + omit_if_support_ml_dsa_key_load use_ui @ui do e = assert_raise Gem::OptionParser::InvalidArgument do @@ -539,7 +539,7 @@ def test_execute_private_ml_dsa_65_key_without_ml_dsa_support end def test_execute_encrypted_private_ml_dsa_65_key - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load use_ui @ui do @cmd.send :handle_options, diff --git a/test/rubygems/test_gem_security_policy.rb b/test/rubygems/test_gem_security_policy.rb index ae264c0370c2..a15c83b7ca1d 100644 --- a/test/rubygems/test_gem_security_policy.rb +++ b/test/rubygems/test_gem_security_policy.rb @@ -54,7 +54,7 @@ def test_check_data end def test_check_data_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load data = digest "hello" @@ -78,7 +78,7 @@ def test_check_data_invalid end def test_check_data_invalid_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load data = digest "hello" @@ -249,7 +249,7 @@ def test_check_trust end def test_check_trust_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load Gem::Security.trust_dir.trust_cert ML_DSA_65_PUBLIC_CERT @@ -427,7 +427,7 @@ def test_verify_wrong_digest_type end def test_verify_ml_dsa_65_without_ml_dsa_support - omit_if_support_ml_dsa_key + omit_if_support_ml_dsa_key_load e = assert_raise Gem::Security::Exception do @high.verify [ML_DSA_65_PUBLIC_CERT], nil, *dummy_signatures diff --git a/test/rubygems/test_gem_security_signer.rb b/test/rubygems/test_gem_security_signer.rb index be79909453a4..6d99be533d35 100644 --- a/test/rubygems/test_gem_security_signer.rb +++ b/test/rubygems/test_gem_security_signer.rb @@ -70,7 +70,7 @@ def test_initialize_key_path end def test_initialize_key_path_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load key_file = ML_DSA_65_PRIVATE_KEY_FILE @@ -80,7 +80,7 @@ def test_initialize_key_path_ml_dsa_65 end def test_initialize_key_path_ml_dsa_65_without_ml_dsa_support - omit_if_support_ml_dsa_key + omit_if_support_ml_dsa_key_load key_file = ML_DSA_65_PRIVATE_KEY_FILE @@ -103,7 +103,7 @@ def test_initialize_encrypted_key_path end def test_initialize_encrypted_key_path_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load key_file = ML_DSA_65_ENCRYPTED_PRIVATE_KEY_FILE @@ -153,7 +153,7 @@ def test_sign end def test_sign_ml_dsa_65 - omit_unless_support_ml_dsa_key + omit_unless_support_ml_dsa_key_load signer = Gem::Security::Signer.new ML_DSA_65_PRIVATE_KEY, [ML_DSA_65_PUBLIC_CERT] From a68dbb6c2dfc9f0c984555dcb4e58ef7e7a24bec Mon Sep 17 00:00:00 2001 From: Justin Smith Date: Thu, 3 Sep 2026 16:18:34 -0400 Subject: [PATCH 2/2] Cite RFC 9881 for ML-DSA OIDs --- lib/rubygems/security.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb index 999f7bb9a06c..0a62c4a4ea83 100644 --- a/lib/rubygems/security.rb +++ b/lib/rubygems/security.rb @@ -367,10 +367,11 @@ class Exception < Gem::Exception; end ML_DSA_65_NAME = "ML-DSA-65" ML_DSA_87_NAME = "ML-DSA-87" - # ML-DSA SubjectPublicKeyInfo algorithm OIDs (NIST FIPS 204). Matching the OID - # rather than the algorithm name keeps this working on SSL libraries that - # spell the name differently: AWS-LC registers 2.16.840.1.101.3.4.3.18 as - # "MLDSA65", not "ML-DSA-65". + # ML-DSA SubjectPublicKeyInfo algorithm OIDs (RFC 9881 Sections 2 and 4). + # https://www.rfc-editor.org/rfc/rfc9881.html + # Matching the OID rather than the algorithm name keeps this working on SSL + # libraries that spell the name differently: AWS-LC registers + # 2.16.840.1.101.3.4.3.18 as "MLDSA65", not "ML-DSA-65". ML_DSA_OIDS = %w[ 2.16.840.1.101.3.4.3.17