diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 236e43a..8c4d619 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,10 @@ jobs: name_suffix: [''] experimental: [false] include: + - os: ubuntu-22.04 + ruby: '3.1' + name_suffix: '' + experimental: false - os: ubuntu-20.04 ruby: head name_suffix: ' (experimental)' @@ -20,6 +24,10 @@ jobs: ruby: jruby-head name_suffix: ' (experimental)' experimental: true + - os: ubuntu-22.04 + ruby: head + name_suffix: ' (experimental)' + experimental: true - os: windows-2019 ruby: head name_suffix: ' (experimental)' @@ -60,6 +68,8 @@ jobs: - run: ruby --version - run: gem --version - run: bundle --version + - name: OpenSSL Version + run: ruby -ropenssl -e'puts OpenSSL::OPENSSL_VERSION' - run: bundle exec rake test env: TESTOPTS: --verbose diff --git a/lib/putty/key/openssl.rb b/lib/putty/key/openssl.rb index 0774c95..50fe1ca 100644 --- a/lib/putty/key/openssl.rb +++ b/lib/putty/key/openssl.rb @@ -25,10 +25,266 @@ module OpenSSL private_constant :SSH_CURVES + # OpenSSL version helper methods. + # + # @private + module Version + class << self + # Determines if the Ruby OpenSSL wrapper is using the OpenSSL library + # (not LibreSSL and not JRuby) and if the version matches the required + # version. + # + # @param major [Integer] The required major version. `nil` if any + # version of OpenSSL is sufficient. + # @param minor [Integer] The required minor version. + # @param fix [Integer] The required fix version. + # @param patch [Integer] The required patch version. + # @return [Boolean] `true` if the requirements are met, otherwise + # `false`. + def openssl?(major = nil, minor = 0, fix = 0, patch = 0) + return false if ::OpenSSL::OPENSSL_VERSION.include?('LibreSSL') + return false if ::OpenSSL::OPENSSL_VERSION.include?('JRuby') + return true unless major + required_version = major * 0x10000000 + minor * 0x100000 + fix * 0x1000 + patch * 0x10 + ::OpenSSL::OPENSSL_VERSION_NUMBER >= required_version + end + end + end + private_constant :Version + + # Methods to build OpenSSL private keys from a {PPK}. + # + # @private + module PKeyBuilding + class << self + # Creates a new OpenSSL DSA private key for the given DSA {PPK}. + # + # @param ppk [PPK] A DSA {PPK}. + # @return [::OpenSSL::PKey::DSA] The OpenSSL DSA private key. + def ppk_to_dsa(ppk) + _, p, q, g, pub_key = Util.ssh_unpack(ppk.public_blob, :string, :mpint, :mpint, :mpint, :mpint) + priv_key = Util.ssh_unpack(ppk.private_blob, :mpint).first + dsa_from_params(p, q, g, pub_key, priv_key) + end + + # Creates a new OpenSSL RSA private key for the given RSA {PPK}. + # + # @param ppk [PPK] An RSA {PPK}. + # @return [::OpenSSL::PKey::RSA] The OpenSSL RSA private key. + def ppk_to_rsa(ppk) + _, e, n = Util.ssh_unpack(ppk.public_blob, :string, :mpint, :mpint) + d, p, q, iqmp = Util.ssh_unpack(ppk.private_blob, :mpint, :mpint, :mpint, :mpint) + dmp1 = d % (p - 1) + dmq1 = d % (q - 1) + rsa_from_params(e, n, d, p, q, iqmp, dmp1, dmq1) + end + + # Creates a new OpenSSL EC private key for the given EC {PPK}. + # + # @param ppk [PPK] An EC {PPK}. + # @param ppk_curve [String] The PPK curve name extracted from the + # PPK algorithm name. + # @return [::OpenSSL::PKey::EC] The OpenSSL EC private key. + def ppk_to_ec(ppk, ppk_curve) + curve = OPENSSL_CURVES[ppk_curve] + _, _, pub_key = Util.ssh_unpack(ppk.public_blob, :string, :string, :mpint) + priv_key = Util.ssh_unpack(ppk.private_blob, :mpint).first + ec_from_params(curve, pub_key, priv_key) + end + + private + + if Version.openssl?(3) + # OpenSSL v3 private keys are immutable. The Ruby OpenSSL wrapper + # doesn't provide a method to construct private keys using the + # parameters. Build DER (ASN.1) encoded versions of the keys. + # + # In theory this should be usable universally. However + # ::OpenSSL::PKey::EC::Point#to_octet_string is only supported from + # Ruby >= 2.4 and there are issues with JRuby's OpenSSL library + # (that doesn't make use of OpenSSL). + + # :nocov_no_openssl3: + + # Creates a new OpenSSL DSA private key with the given parameters. + # + # @param p [::OpenSSL::BN] The p parameter (prime). + # @param q [::OpenSSL::BN] The q parameter (prime). + # @param g [::OpenSSL::BN] The g parameter. + # @param pub_key [::OpenSSL::BN] The public key. + # @param priv_key [::OpenSSL::BN] The private key. + # @return [::OpenSSL::PKey::DSA] The OpenSSL DSA private key. + def dsa_from_params(p, q, g, pub_key, priv_key) + # https://www.openssl.org/docs/man3.0/man1/openssl-dsa.html (outform parameter). + sequence = [ + ::OpenSSL::ASN1::Integer.new(0), + ::OpenSSL::ASN1::Integer.new(p), + ::OpenSSL::ASN1::Integer.new(q), + ::OpenSSL::ASN1::Integer.new(g), + ::OpenSSL::ASN1::Integer.new(pub_key), + ::OpenSSL::ASN1::Integer.new(priv_key) + ] + + ::OpenSSL::PKey::DSA.new(::OpenSSL::ASN1::Sequence.new(sequence).to_der) + end + + # Creates a new OpenSSL RSA private key with the given parameters. + # + # @param e [::OpenSSL::BN] The public key exponent. + # @param n [::OpenSSL::BN] The modulus. + # @param d [::OpenSSL::BN] The private key exponent. + # @param p [::OpenSSL::BN] The p prime. + # @param q [::OpenSSL::BN] The q prime. + # @param iqmp [::OpenSSL::BN] The inverse of q, mod p. + # @param dmp1 [::OpenSSL::BN] `d` mod (`p` - 1). + # @param dmq1 [::OpenSSL::BN] `d` mod (`q` - 1). + # @return [::OpenSSL::PKey::RSA] The OpenSSL RSA private key. + def rsa_from_params(e, n, d, p, q, iqmp, dmp1, dmq1) + # RFC 3447 Appendix A.1.2 + sequence = [ + ::OpenSSL::ASN1::Integer.new(0), + ::OpenSSL::ASN1::Integer.new(n), + ::OpenSSL::ASN1::Integer.new(e), + ::OpenSSL::ASN1::Integer.new(d), + ::OpenSSL::ASN1::Integer.new(p), + ::OpenSSL::ASN1::Integer.new(q), + ::OpenSSL::ASN1::Integer.new(dmp1), + ::OpenSSL::ASN1::Integer.new(dmq1), + ::OpenSSL::ASN1::Integer.new(iqmp) + ] + + ::OpenSSL::PKey::RSA.new(::OpenSSL::ASN1::Sequence.new(sequence).to_der) + end + + # Creates a new OpenSSL EC private key with the given parameters. + # + # @param curve [String] The name of the OpenSSL EC curve. + # @param pub_key [::OpenSSL::BN] The public key. + # @param priv_key [::OpenSSL::BN] The private key. + # @return [::OpenSSL::PKey::EC] The OpenSSL EC private key. + def ec_from_params(curve, pub_key, priv_key) + group = ::OpenSSL::PKey::EC::Group.new(curve) + point = ::OpenSSL::PKey::EC::Point.new(group, pub_key) + point_string = point.to_octet_string(:uncompressed) + + # RFC 5915 Section 3 + sequence = [ + ::OpenSSL::ASN1::Integer.new(1), + ::OpenSSL::ASN1::OctetString.new(priv_key.to_s(2)), + ::OpenSSL::ASN1::ObjectId.new(curve, 0, :EXPLICIT), + ::OpenSSL::ASN1::BitString.new(point_string, 1, :EXPLICIT) + ] + + ::OpenSSL::PKey::EC.new(::OpenSSL::ASN1::Sequence.new(sequence).to_der) + end + # :nocov_no_openssl3: + else + # :nocov_openssl3: + if ::OpenSSL::PKey::DSA.new.respond_to?(:set_key) + # :nocov_no_openssl_pkey_dsa_set_key: + + # Creates a new OpenSSL DSA private key with the given parameters. + # + # @param p [::OpenSSL::BN] The p parameter. + # @param q [::OpenSSL::BN] The q parameter. + # @param g [::OpenSSL::BN] The g parameter. + # @param pub_key [::OpenSSL::BN] The public key. + # @param priv_key [::OpenSSL::BN] The private key. + # @return [::OpenSSL::PKey::DSA] The OpenSSL DSA private key. + def dsa_from_params(p, q, g, pub_key, priv_key) + ::OpenSSL::PKey::DSA.new.tap do |pkey| + pkey.set_key(pub_key, priv_key) + pkey.set_pqg(p, q, g) + end + end + # :nocov_no_openssl_pkey_dsa_set_key: + else + # :nocov_openssl_pkey_dsa_set_key: + # Creates a new OpenSSL DSA private key with the given parameters. + # + # @param p [::OpenSSL::BN] The p parameter. + # @param q [::OpenSSL::BN] The q parameter. + # @param g [::OpenSSL::BN] The g parameter. + # @param pub_key [::OpenSSL::BN] The public key. + # @param priv_key [::OpenSSL::BN] The private key. + # @return [::OpenSSL::PKey::DSA] The OpenSSL DSA private key. + def dsa_from_params(p, q, g, pub_key, priv_key) + ::OpenSSL::PKey::DSA.new.tap do |pkey| + pkey.p, pkey.q, pkey.g, pkey.pub_key, pkey.priv_key = p, q, g, pub_key, priv_key + end + end + # :nocov_openssl_pkey_dsa_set_key: + end + + if ::OpenSSL::PKey::RSA.new.respond_to?(:set_factors) + # :nocov_no_openssl_pkey_rsa_set_factors: + + # Creates a new OpenSSL RSA private key with the given parameters. + # + # @param e [::OpenSSL::BN] The public key exponent. + # @param n [::OpenSSL::BN] The modulus. + # @param d [::OpenSSL::BN] The private key exponent. + # @param p [::OpenSSL::BN] The p prime. + # @param q [::OpenSSL::BN] The q prime. + # @param iqmp [::OpenSSL::BN] The inverse of q, mod p. + # @param dmp1 [::OpenSSL::BN] `d` mod (`p` - 1). + # @param dmq1 [::OpenSSL::BN] `d` mod (`q` - 1). + # @return [::OpenSSL::PKey::RSA] The OpenSSL RSA private key. + def rsa_from_params(e, n, d, p, q, iqmp, dmp1, dmq1) + ::OpenSSL::PKey::RSA.new.tap do |pkey| + pkey.set_factors(p, q) + pkey.set_key(n, e, d) + pkey.set_crt_params(dmp1, dmq1, iqmp) + end + end + # :nocov_no_openssl_pkey_rsa_set_factors: + else + # :nocov_openssl_pkey_rsa_set_factors: + + # Creates a new OpenSSL RSA private key with the given parameters. + # + # @param e [::OpenSSL::BN] The public key exponent. + # @param n [::OpenSSL::BN] The modulus. + # @param d [::OpenSSL::BN] The private key exponent. + # @param p [::OpenSSL::BN] The p prime. + # @param q [::OpenSSL::BN] The q prime. + # @param iqmp [::OpenSSL::BN] The inverse of q, mod p. + # @param dmp1 [::OpenSSL::BN] `d` mod (`p` - 1). + # @param dmq1 [::OpenSSL::BN] `d` mod (`q` - 1). + # @return [::OpenSSL::PKey::RSA] The OpenSSL RSA private key. + def rsa_from_params(e, n, d, p, q, iqmp, dmp1, dmq1) + ::OpenSSL::PKey::RSA.new.tap do |pkey| + pkey.e, pkey.n, pkey.d, pkey.p, pkey.q, pkey.iqmp, pkey.dmp1, pkey.dmq1 = e, n, d, p, q, iqmp, dmp1, dmq1 + end + end + # :nocov_openssl_pkey_rsa_set_factors: + end + + # Creates a new OpenSSL EC private key with the given parameters. + # + # @param curve [String] The name of the OpenSSL EC curve. + # @param pub_key [::OpenSSL::BN] The public key. + # @param priv_key [::OpenSSL::BN] The private key. + # @return [::OpenSSL::PKey::EC] The OpenSSL EC private key. + def ec_from_params(curve, pub_key, priv_key) + # Old versions of jruby-openssl don't include an EC class (version 0.9.16). + ec_class = (::OpenSSL::PKey::EC rescue raise ArgumentError, "Unsupported algorithm: #{ppk.algorithm}") + + ec_class.new(curve).tap do |pkey| + group = pkey.group || ::OpenSSL::PKey::EC::Group.new(curve) + pkey.public_key = ::OpenSSL::PKey::EC::Point.new(group, pub_key) + pkey.private_key = priv_key + end + end + # :nocov_openssl3: + end + end + end + private_constant :PKeyBuilding + # The {ClassMethods} module is used to extend `OpenSSL::PKey` when # using the PuTTY::Key refinement or calling {PuTTY::Key.global_install}. # This adds a `from_ppk` class method to `OpenSSL::PKey`. - # module ClassMethods # Creates a new `OpenSSL::PKey` from a PuTTY private key (instance of # {PPK}). @@ -50,53 +306,11 @@ def from_ppk(ppk) case ppk.algorithm when 'ssh-dss' - ::OpenSSL::PKey::DSA.new.tap do |pkey| - _, p, q, g, pub_key = Util.ssh_unpack(ppk.public_blob, :string, :mpint, :mpint, :mpint, :mpint) - priv_key = Util.ssh_unpack(ppk.private_blob, :mpint).first - - if pkey.respond_to?(:set_key) - # :nocov_no_openssl_pkey_dsa_set_key: - pkey.set_key(pub_key, priv_key) - pkey.set_pqg(p, q, g) - # :nocov_no_openssl_pkey_dsa_set_key: - else - # :nocov_openssl_pkey_dsa_set_key: - pkey.p, pkey.q, pkey.g, pkey.pub_key, pkey.priv_key = p, q, g, pub_key, priv_key - # :nocov_openssl_pkey_dsa_set_key: - end - end + PKeyBuilding.ppk_to_dsa(ppk) when 'ssh-rsa' - ::OpenSSL::PKey::RSA.new.tap do |pkey| - _, e, n = Util.ssh_unpack(ppk.public_blob, :string, :mpint, :mpint) - d, p, q, iqmp = Util.ssh_unpack(ppk.private_blob, :mpint, :mpint, :mpint, :mpint) - - dmp1 = d % (p - 1) - dmq1 = d % (q - 1) - - if pkey.respond_to?(:set_factors) - # :nocov_no_openssl_pkey_rsa_set_factors: - pkey.set_factors(p, q) - pkey.set_key(n, e, d) - pkey.set_crt_params(dmp1, dmq1, iqmp) - # :nocov_no_openssl_pkey_rsa_set_factors: - else - # :nocov_openssl_pkey_rsa_set_factors: - pkey.e, pkey.n, pkey.d, pkey.p, pkey.q, pkey.iqmp, pkey.dmp1, pkey.dmq1 = e, n, d, p, q, iqmp, dmp1, dmq1 - # :nocov_openssl_pkey_rsa_set_factors: - end - end + PKeyBuilding.ppk_to_rsa(ppk) when /\Aecdsa-sha2-(nistp(?:256|384|521))\z/ - curve = OPENSSL_CURVES[$1] - - # Old versions of jruby-openssl don't include an EC class (version 0.9.16). - ec_class = (::OpenSSL::PKey::EC rescue raise ArgumentError, "Unsupported algorithm: #{ppk.algorithm}") - - ec_class.new(curve).tap do |pkey| - _, _, point = Util.ssh_unpack(ppk.public_blob, :string, :string, :mpint) - group = pkey.group || ::OpenSSL::PKey::EC::Group.new(curve) - pkey.public_key = ::OpenSSL::PKey::EC::Point.new(group, point) - pkey.private_key = Util.ssh_unpack(ppk.private_blob, :mpint).first - end + PKeyBuilding.ppk_to_ec(ppk, $1) else raise ArgumentError, "Unsupported algorithm: #{ppk.algorithm}" end diff --git a/lib/putty/key/ppk.rb b/lib/putty/key/ppk.rb index 0a61361..10b7d30 100644 --- a/lib/putty/key/ppk.rb +++ b/lib/putty/key/ppk.rb @@ -319,7 +319,19 @@ def get_argon2_type(key_derivation) def derive_keys(format, cipher = nil, passphrase = nil, argon2_params = nil) if format >= 3 return derive_format_3_keys(cipher, passphrase, argon2_params) if cipher - return [''.b, nil, nil, nil] + + # An empty string should work for the MAC, but ::OpenSSL::HMAC fails + # when used with OpenSSL 3: + # + # EVP_PKEY_new_mac_key: malloc failure (OpenSSL::HMACError). + # + # See https://github.com/ruby/openssl/pull/538 and + # https://github.com/openssl/openssl/issues/13089. + # + # Ruby 3.1.3 should contain the workaround from ruby/openssl PR 538. + # + # Use "\0" as the MAC key for a workaround for Ruby < 3.1.3. + return ["\0".b, nil, nil, nil] end mac_key = derive_format_2_mac_key(passphrase) diff --git a/test/test_helper.rb b/test/test_helper.rb index cceea16..0cef030 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'putty/key' + TEST_TYPE = (ENV['TEST_TYPE'] || 'refinement').to_sym raise "Unrecognized TEST_TYPE: #{TEST_TYPE}" unless [:refinement, :global].include?(TEST_TYPE) @@ -15,7 +17,10 @@ "#{object.respond_to?(method) ? '' : 'no_'}#{Regexp.escape(object.class.name.downcase.gsub('::', '_'))}_#{Regexp.escape(method)}" end - feature_support = [['refinement_class', defined?(Refinement)]].map do |feature, available| + feature_support = [ + ['openssl3', PuTTY::Key::OpenSSL.const_get(:Version).openssl?(3)], + ['refinement_class', defined?(Refinement)] + ].map do |feature, available| "#{available ? '' : 'no_'}#{feature}" end @@ -32,8 +37,6 @@ end end -require 'putty/key' - require 'fileutils' require 'minitest/autorun' require 'tmpdir'