diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index dc1d0aab..d24b1dcf 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -31,6 +31,9 @@ jobs: - java: 21 distribution: oracle ruby: jruby-9.4.15.0 + - java: 17 + distribution: temurin + ruby: jruby-9.4.15.0 - java: 11 distribution: corretto ruby: jruby-9.4.14.0 diff --git a/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java b/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java index 0356a59b..312ae105 100644 --- a/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java +++ b/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java @@ -67,7 +67,18 @@ private PKeyEdDSA(Ruby runtime, RubyClass type, PublicKey publicKey, PrivateKey } PKeyEdDSA(Ruby runtime, PublicKey publicKey) { - this(runtime, publicKey, null); + this(runtime, convertNonBCPublicKey(runtime, publicKey), null); + } + + // handle JDK (SunEC) keys e.g. from SunJSSE certificates + private static PublicKey convertNonBCPublicKey(final Ruby runtime, final PublicKey publicKey) { + if (publicKey instanceof EdDSAPublicKey) return publicKey; + try { + return SecurityHelper.getKeyFactory("EdDSA") + .generatePublic(new X509EncodedKeySpec(publicKey.getEncoded())); + } catch (GeneralSecurityException e) { + throw newPKeyError(runtime, e); + } } static PKeyEdDSA newInstance(final Ruby runtime, final KeyPair keyPair) { @@ -326,12 +337,10 @@ private static StringBuilder bytesToHex(byte[] bytes) { } static boolean isEdDSAAlgorithm(final String alg) { - return "EdDSA".equalsIgnoreCase(alg) || - "Ed25519".equalsIgnoreCase(alg) || - "Ed448".equalsIgnoreCase(alg); + return "EdDSA".equalsIgnoreCase(alg) || "Ed25519".equalsIgnoreCase(alg) || "Ed448".equalsIgnoreCase(alg); } static boolean isEdDSAKey(final PublicKey key) { - return key instanceof EdDSAPublicKey; + return key instanceof EdDSAPublicKey || (key != null && isEdDSAAlgorithm(key.getAlgorithm())); } } diff --git a/src/main/java/org/jruby/ext/openssl/SSLContext.java b/src/main/java/org/jruby/ext/openssl/SSLContext.java index 355999bc..65033657 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLContext.java +++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java @@ -28,6 +28,7 @@ package org.jruby.ext.openssl; import java.net.Socket; +import java.security.AlgorithmParameters; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -37,14 +38,19 @@ import java.util.Set; import java.security.GeneralSecurityException; +import java.security.KeyFactory; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; +import java.security.interfaces.ECKey; +import java.security.spec.ECGenParameterSpec; +import java.security.spec.ECParameterSpec; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; import java.util.Collections; +import java.util.Locale; import javax.net.ssl.KeyManager; import javax.net.ssl.SSLEngine; @@ -1091,6 +1097,30 @@ private List buildCredentials(final X509Cert cert, final PKey key, return credentials; } + // NOTE: only used under SunJSSE (engine) provider + private static Credential translateEdDSAKeys(final Ruby runtime, final Credential credential) { + final PublicKey publicKey = credential.leaf.getPublicKey(); + if (!"EdDSA".equals(credential.keyType) || publicKey == null) return credential; + if ("EdDSA".equals(publicKey.getAlgorithm()) && "EdDSA".equals(credential.privateKey.getAlgorithm())) { + return credential; + } + try { + final KeyFactory keyFactory = KeyFactory.getInstance("EdDSA", "SunEC"); // Java 15+ + final PrivateKey privateKey = (PrivateKey) keyFactory.translateKey(credential.privateKey); + final PublicKey sunPublicKey = (PublicKey) keyFactory.translateKey(publicKey); + // same certificate bytes, only the public key object differs + final X509AuxCertificate leaf = new X509AuxCertificate(credential.leaf) { + @Override + public PublicKey getPublicKey() { return sunPublicKey; } + }; + return new Credential(credential.alias, leaf, privateKey, credential.keyType, credential.chain); + } + catch (GeneralSecurityException e) { + LOG.warn(runtime, "SunJSSE can not use " + publicKey.getAlgorithm() + " credential: ", e); + return credential; + } + } + private InternalContext initSSLContext(ThreadContext context, final Store store, final List clientCA, final int verifyMode, final IRubyObject verifyCallback, final int verifyDepth, final int timeout, @@ -1129,8 +1159,6 @@ private class InternalContext { final RubyProc alpnSelectCallback, final List certsAndKeys) throws NoSuchAlgorithmException { - this.credentials = certsAndKeys; - this.store = store; this.clientCA = clientCA; this.verifyMode = verifyMode; @@ -1141,6 +1169,24 @@ private class InternalContext { this.alpnSelectCallback = alpnSelectCallback; this.sslContext = SecurityHelper.getSSLContext(protocol); + this.credentials = convertCredentialsIfNeeded(certsAndKeys); + } + + private List convertCredentialsIfNeeded(final List certsAndKeys) { + if (!"SunJSSE".equals(sslContext.getProvider().getName())) return certsAndKeys; + + final Ruby runtime = getRuntime(); + // SunJSSE skips EdDSA credentials unless both keys report "EdDSA" (BC keys report Ed25519/Ed448) + List credentials = certsAndKeys; + for (int i = 0; i < certsAndKeys.size(); i++) { + final Credential credential = certsAndKeys.get(i); + final Credential translated = translateEdDSAKeys(runtime, credential); + if (translated != credential) { + if (certsAndKeys == credentials) credentials = new ArrayList<>(certsAndKeys); + credentials.set(i, translated); + } + } + return credentials; } void initSSLContext(final ThreadContext context) throws NoSuchAlgorithmException, KeyManagementException { @@ -1248,18 +1294,60 @@ public String chooseServerAlias(String keyType, java.security.Principal[] issuer return chooseAlias(keyType); } - // BCJSSE asks per negotiated key type (RSA / EC / ...) + // BCJSSE asks for cipher key types in TLS 1.2 and named curves in TLS 1.3 private String chooseAlias(final String keyType) { if (keyType == null) return null; for (int i = 0; i < internalContext.credentials.size(); i++) { final Credential credential = internalContext.credentials.get(i); - if (credential.privateKey != null && keyType.equalsIgnoreCase(credential.keyType)) { + if (credential.privateKey != null && keyTypeMatches(keyType, credential)) { return credential.alias; // pick the first credential that matches } } return null; } + private static boolean keyTypeMatches(final String requested, final Credential credential) { + // BCJSSE may ask for cipher or signature names where SunJSSE asks for key algorithms + if (requested.equalsIgnoreCase(credential.keyType)) return true; + + switch (requested.toUpperCase(Locale.ROOT)) { + case "ECDHE_ECDSA": + return "EC".equalsIgnoreCase(credential.keyType); + case "ECDHE_RSA": case "DHE_RSA": + return "RSA".equalsIgnoreCase(credential.keyType); + case "DHE_DSS": + return "DSA".equalsIgnoreCase(credential.keyType); + case "ED25519": case "ED448": // EdDSA alone cannot distinguish Ed25519 from Ed448 + if (!"EdDSA".equalsIgnoreCase(credential.keyType)) return false; + final PublicKey publicKey = credential.leaf.getPublicKey(); + return (publicKey != null && requested.equalsIgnoreCase(publicKey.getAlgorithm())) || + requested.equalsIgnoreCase(credential.privateKey.getAlgorithm()); + default: + if (!"EC".equalsIgnoreCase(credential.keyType) || + !requested.regionMatches(true, 0, "EC/", 0, 3)) return false; + return matchesCurve(credential.leaf.getPublicKey(), requested.substring(3)); + } + } + + private static boolean matchesCurve(final PublicKey publicKey, final String namedGroup) { + if (!(publicKey instanceof ECKey)) return false; + + try { + final AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); + parameters.init(new ECGenParameterSpec(namedGroup)); + final ECParameterSpec expected = parameters.getParameterSpec(ECParameterSpec.class); + final ECParameterSpec actual = ((ECKey) publicKey).getParams(); + // BCJSSE names the TLS 1.3 group; EC alone could select a certificate on another curve + return actual != null && expected.getCurve().equals(actual.getCurve()) && + expected.getGenerator().equals(actual.getGenerator()) && + expected.getOrder().equals(actual.getOrder()) && + expected.getCofactor() == actual.getCofactor(); + } + catch (GeneralSecurityException | IllegalArgumentException e) { + return false; + } + } + private Credential credentialByAlias(String alias) { if (alias == null) return null; for (int i = 0; i < internalContext.credentials.size(); i++) { diff --git a/test/ssl/test_ssl.rb b/test/ssl/test_ssl.rb index ce3dfa92..aa7317a7 100644 --- a/test/ssl/test_ssl.rb +++ b/test/ssl/test_ssl.rb @@ -117,6 +117,13 @@ def create_cert_with_san(san) cert end + # JDK 15 SunEC has EdDSA keys but its JSSE still disables EdDSA for TLS (JDK-8166596, Java 16) + def sunjsse_without_eddsa?(protocol) + return unless jruby? + provider = org.jruby.ext.openssl.SecurityHelper.getSSLContext(protocol).provider.name + provider == 'SunJSSE' && self.class.java_version.last.to_i < 16 + end + public # Ported from CRuby's test_verify_hostname_on_connect (test/openssl/test_ssl.rb). @@ -782,6 +789,169 @@ def test_add_certificate_multiple_certs } end + def test_explicit_tls_server_credential_key_types + keys = { + 'P-256' => OpenSSL::PKey::EC.generate('prime256v1'), + 'P-384' => OpenSSL::PKey::EC.generate('secp384r1') + } + keys['Ed25519'] = OpenSSL::PKey.generate_key('ED25519') if !fips? && !sunjsse_without_eddsa?('TLSv1.2') + + keys.each_with_index do |(name, key), index| + subject = OpenSSL::X509::Name.parse("/CN=#{name}") + cert = issue_cert(subject, key, 500 + index, + [['keyUsage', 'digitalSignature', true]], @ca_cert, @ca_key) + + [:TLSv1_2, :TLSv1_3].each do |version| + next if fips? && version == :TLSv1_2 + %w[legacy add_certificate].each do |registration| + set_version = proc do |ctx| + if jruby? + ctx.ssl_version = version + else + value = version == :TLSv1_2 ? OpenSSL::SSL::TLS1_2_VERSION : OpenSSL::SSL::TLS1_3_VERSION + ctx.min_version = ctx.max_version = value + end + end + configure = proc do |ctx| + set_version.call(ctx) + ctx.cert = ctx.key = nil + if registration == 'legacy' + ctx.cert = cert + ctx.key = key + ctx.extra_chain_cert = [@ca_cert] + else + ctx.add_certificate(cert, key, [@ca_cert]) + end + end + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + client_ctx = OpenSSL::SSL::SSLContext.new + set_version.call(client_ctx) + server_connect(port, client_ctx) do |ssl| + assert_equal version == :TLSv1_2 ? 'TLSv1.2' : 'TLSv1.3', ssl.ssl_version + assert_equal cert.to_der, ssl.peer_cert.to_der, "#{name} #{version} #{registration}" + ssl.puts 'ping' + assert_equal "ping\n", ssl.gets + end + end + end + end + end + end + + def test_explicit_tls_add_certificate_selects_rsa_and_ec + skip('BCJSSE FIPS does not offer ECDHE-ECDSA in TLS 1.2') if fips? + + ec_key = OpenSSL::PKey::EC.generate('prime256v1') + ec_subject = OpenSSL::X509::Name.parse('/CN=EC credential') + ec_cert = issue_cert(ec_subject, ec_key, 510, + [['keyUsage', 'digitalSignature', true]], @ca_cert, @ca_key) + configure = proc do |ctx| + ctx.ssl_version = :TLSv1_2 if jruby? + ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION + ctx.cert = ctx.key = nil + ctx.add_certificate(@svr_cert, @svr_key, [@ca_cert]) + ctx.add_certificate(ec_cert, ec_key, [@ca_cert]) + end + + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + { + 'ECDHE-ECDSA-AES128-GCM-SHA256' => ec_cert, + 'ECDHE-RSA-AES128-GCM-SHA256' => @svr_cert + }.each do |cipher, expected| + client_ctx = OpenSSL::SSL::SSLContext.new + client_ctx.min_version = client_ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION + client_ctx.ciphers = cipher + server_connect(port, client_ctx) do |ssl| + assert_equal 'TLSv1.2', ssl.ssl_version + assert_equal expected.to_der, ssl.peer_cert.to_der, cipher + ssl.puts 'ping' + assert_equal "ping\n", ssl.gets + end + end + end + end + + # BCJSSE asks clients for EC/ in TLS 1.3 + def test_explicit_tls13_ec_client_certificate + keys = { + 'P-256' => OpenSSL::PKey::EC.generate('prime256v1'), + 'P-384' => OpenSSL::PKey::EC.generate('secp384r1') + } + configure = proc do |ctx| + if jruby? + ctx.ssl_version = :TLSv1_3 + else + ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION + end + end + server_ctx = proc do |ctx| + configure.call(ctx) + ctx.client_ca = [@ca_cert] + end + report_peer = proc do |_ctx, ssl| + ssl.puts ssl.peer_cert ? ssl.peer_cert.to_der.unpack1('H*') : 'none' + end + verify = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + + keys.each_with_index do |(name, key), index| + subject = OpenSSL::X509::Name.parse("/CN=#{name} client") + cert = issue_cert(subject, key, 520 + index, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', 'clientAuth', false]], @ca_cert, @ca_key) + + %w[legacy add_certificate].each do |registration| + start_server(verify, true, ctx_proc: server_ctx, server_proc: report_peer) do |_server, port| + client_ctx = OpenSSL::SSL::SSLContext.new + configure.call(client_ctx) + if registration == 'legacy' + client_ctx.cert = cert + client_ctx.key = key + else + client_ctx.add_certificate(cert, key, [@ca_cert]) + end + server_connect(port, client_ctx) do |ssl| + assert_equal 'TLSv1.3', ssl.ssl_version + assert_equal cert.to_der.unpack1('H*'), ssl.gets.chomp, "#{name} #{registration}" + end + end + end + end + end + + # SSLContext.new without a version uses SunJSSE (BC EdDSA keys need translation there) + def test_default_context_ed25519_credentials + omit_on_fips + skip 'SunJSSE supports EdDSA in TLS since Java 16' if sunjsse_without_eddsa?('SSL') + + server_key = OpenSSL::PKey.generate_key('ED25519') + server_cert = issue_cert(OpenSSL::X509::Name.parse('/CN=Ed25519 server'), server_key, 530, + [['keyUsage', 'digitalSignature', true]], @ca_cert, @ca_key) + client_key = OpenSSL::PKey.generate_key('ED25519') + client_cert = issue_cert(OpenSSL::X509::Name.parse('/CN=Ed25519 client'), client_key, 531, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', 'clientAuth', false]], @ca_cert, @ca_key) + + configure = proc do |ctx| + ctx.cert = server_cert + ctx.key = server_key + ctx.extra_chain_cert = [@ca_cert] + ctx.client_ca = [@ca_cert] + end + report = proc do |_ctx, ssl| # local key and peer certificate as seen by the server + ssl.puts [ssl.cert.public_key.raw_public_key, ssl.peer_cert.to_der].map { |bytes| bytes.unpack1('H*') }.join(' ') + end + verify = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + start_server(verify, true, ctx_proc: configure, server_proc: report) do |_server, port| + client_ctx = OpenSSL::SSL::SSLContext.new + client_ctx.add_certificate(client_cert, client_key, [@ca_cert]) + server_connect(port, client_ctx) do |ssl| + assert_equal server_cert.to_der, ssl.peer_cert.to_der + assert_equal server_key.raw_public_key, ssl.peer_cert.public_key.raw_public_key + local_key, peer_cert = ssl.gets.split.map { |hex| [hex].pack('H*') } + assert_equal server_key.raw_public_key, local_key + assert_equal client_cert.to_der, peer_cert + end + end + end + def test_add_certificate_validates_private_key_and_chain ctx = OpenSSL::SSL::SSLContext.new public_key = OpenSSL::PKey.read(@svr_key.public_to_der)