diff --git a/.github/workflows/rubygems.yml b/.github/workflows/rubygems.yml index c7419c954fb8..42134c11432c 100644 --- a/.github/workflows/rubygems.yml +++ b/.github/workflows/rubygems.yml @@ -97,6 +97,70 @@ jobs: timeout-minutes: 60 + rubygems-openssl: + name: RubyGems with AWS-LC + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Keep the AWS-LC build and cache behavior aligned with ruby/openssl. + # https://github.com/ruby/openssl/blob/master/.github/workflows/test.yml + # Caches that are accessed at least once a week can remain permanently. + # Include the current week in the key so the latest AWS-LC is built weekly. + # https://github.com/actions/cache#cache-limits + - name: Get current week + id: current-week + run: echo "week=$(date -u '+%Y%W')" >> "$GITHUB_OUTPUT" + + - name: Restore AWS-LC cache + id: cache-aws-lc + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/openssl + key: aws-lc-${{ runner.os }}-${{ runner.arch }}-${{ steps.current-week.outputs.week }} + + - name: Compile AWS-LC + if: steps.cache-aws-lc.outputs.cache-hit != 'true' + run: | + mkdir -p tmp/build-aws-lc + cd tmp/build-aws-lc + git clone https://github.com/aws/aws-lc.git . + AWS_LC_RELEASE=$(git tag --sort=-creatordate --list "v*" | head -1) + git checkout "$AWS_LC_RELEASE" + cmake -DCMAKE_INSTALL_PREFIX="$HOME/openssl" -DCMAKE_INSTALL_LIBDIR=lib + make -j4 + make install + + - name: Setup ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 4.0.5 + bundler: none + + - name: Install Ruby OpenSSL against AWS-LC + run: | + gem install openssl --no-document -- --with-openssl-dir="$HOME/openssl" + ruby -e 'spec = Gem::Specification.find_by_name("openssl"); puts "RUBYLIB=#{spec.full_require_paths.join(File::PATH_SEPARATOR)}"' >> "$GITHUB_ENV" + + - name: Verify AWS-LC + run: | + ruby --disable-gems -ropenssl -e ' + abort "Ruby OpenSSL did not load AWS-LC" unless OpenSSL::OPENSSL_VERSION.include?("AWS-LC") + OpenSSL::PKey.read(File.binread("test/rubygems/mldsa65_private_key.pem")) + puts "Ruby OpenSSL #{OpenSSL::VERSION}: #{OpenSSL::OPENSSL_VERSION}" + ' + + - name: Install Dependencies + run: bin/rake setup + + - name: Run Test + run: bin/rake test + + timeout-minutes: 60 + all-pass: name: All rubygems jobs pass @@ -104,6 +168,7 @@ jobs: needs: - rubygems + - rubygems-openssl runs-on: ubuntu-latest diff --git a/lib/rubygems/security.rb b/lib/rubygems/security.rb index 1d86ecc909cc..0a62c4a4ea83 100644 --- a/lib/rubygems/security.rb +++ b/lib/rubygems/security.rb @@ -367,8 +367,18 @@ 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 (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 + 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 +537,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 09b7f427ebbf..d176e4bdc5f7 100644 --- a/test/rubygems/helper.rb +++ b/test/rubygems/helper.rb @@ -1768,6 +1768,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? @@ -1781,6 +1786,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 771eb07dbc9c..9704a899e8d0 100644 --- a/test/rubygems/test_gem_commands_build_command.rb +++ b/test/rubygems/test_gem_commands_build_command.rb @@ -797,7 +797,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 @@ -830,7 +830,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]