Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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()));
}
}
96 changes: 92 additions & 4 deletions src/main/java/org/jruby/ext/openssl/SSLContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1091,6 +1097,30 @@ private List<Credential> 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<X509AuxCertificate> clientCA,
final int verifyMode, final IRubyObject verifyCallback, final int verifyDepth, final int timeout,
Expand Down Expand Up @@ -1129,8 +1159,6 @@ private class InternalContext {
final RubyProc alpnSelectCallback,
final List<Credential> certsAndKeys) throws NoSuchAlgorithmException {

this.credentials = certsAndKeys;

this.store = store;
this.clientCA = clientCA;
this.verifyMode = verifyMode;
Expand All @@ -1141,6 +1169,24 @@ private class InternalContext {
this.alpnSelectCallback = alpnSelectCallback;

this.sslContext = SecurityHelper.getSSLContext(protocol);
this.credentials = convertCredentialsIfNeeded(certsAndKeys);
}

private List<Credential> convertCredentialsIfNeeded(final List<Credential> 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<Credential> 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 {
Expand Down Expand Up @@ -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++) {
Expand Down
170 changes: 170 additions & 0 deletions test/ssl/test_ssl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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/<named group> 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)
Expand Down
Loading