From b24bd94eb85dda83bd19411305792608c64c5ba5 Mon Sep 17 00:00:00 2001 From: kares Date: Fri, 23 Oct 2026 13:59:58 +0200 Subject: [PATCH 1/4] initial draft: deliver buffered TLS data before EOF defer TLS closure validation until decrypted plaintext is drained, so framed responses can be consumed before a missing close_notify --- pom.xml | 1 + .../java/org/jruby/ext/openssl/PKeyEdDSA.java | 14 +- .../org/jruby/ext/openssl/SSLContext.java | 196 ++++++------- .../org/jruby/ext/openssl/SSLKeyStore.java | 145 +++++++++ .../jruby/ext/openssl/SSLKeyStoreTest.java | 166 +++++++++++ test/ssl/test_credentials.rb | 276 ++++++++++++++++++ 6 files changed, 687 insertions(+), 111 deletions(-) create mode 100644 src/main/java/org/jruby/ext/openssl/SSLKeyStore.java create mode 100644 src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java create mode 100644 test/ssl/test_credentials.rb diff --git a/pom.xml b/pom.xml index 04cf8e88..4d186396 100644 --- a/pom.xml +++ b/pom.xml @@ -1046,6 +1046,7 @@ DO NOT MODIFY - GENERATED CODE org/jruby/ext/openssl/Random.java org/jruby/ext/openssl/SSL.java org/jruby/ext/openssl/SSLContext.java + org/jruby/ext/openssl/SSLKeyStore.java org/jruby/ext/openssl/SSLSession.java org/jruby/ext/openssl/SSLSocket.java org/jruby/ext/openssl/SecurityHelper.java diff --git a/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java b/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java index 0356a59b..154ef059 100644 --- a/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java +++ b/src/main/java/org/jruby/ext/openssl/PKeyEdDSA.java @@ -67,7 +67,17 @@ private PKeyEdDSA(Ruby runtime, RubyClass type, PublicKey publicKey, PrivateKey } PKeyEdDSA(Ruby runtime, PublicKey publicKey) { - this(runtime, publicKey, null); + this(runtime, convertNonBCPublicKey(runtime, publicKey), null); + } + + static PublicKey convertNonBCPublicKey(Ruby runtime, 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) { @@ -332,6 +342,6 @@ static boolean isEdDSAAlgorithm(final String 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..4e19bfa1 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLContext.java +++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java @@ -28,16 +28,18 @@ package org.jruby.ext.openssl; import java.net.Socket; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.security.GeneralSecurityException; -import java.security.KeyManagementException; +import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; @@ -46,14 +48,19 @@ import java.util.Arrays; import java.util.Collections; -import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSessionContext; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509ExtendedKeyManager; import javax.net.ssl.X509ExtendedTrustManager; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.edec.EdECObjectIdentifiers; +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; + import org.jruby.Ruby; import org.jruby.RubyArray; import org.jruby.RubyClass; @@ -332,7 +339,7 @@ public SSLContext(Ruby runtime, RubyClass type) { transient volatile int sessionCacheNum; private volatile InternalContext internalContext; - private final ArrayList addedCredentials = new ArrayList<>(); // @certs_and_keys in MRI + private final Map addedCredentials = new LinkedHashMap<>(); // @certs_and_keys in MRI @JRubyMethod(required = 0, optional = 1, visibility = Visibility.PRIVATE) public IRubyObject initialize(IRubyObject[] args) { @@ -551,8 +558,10 @@ public IRubyObject add_certificate(final ThreadContext context, final IRubyObjec extraChain = null; } - final String alias = Integer.toString(addedCredentials.size()); - addedCredentials.add(new Credential(alias, auxCert, privateKey, key.getKeyType(), extraChain)); + final String alias = keyAlgorithmId(certPublicKey).getId(); + final Credential previous = addedCredentials.get(alias); + addedCredentials.put(alias, new Credential(alias, auxCert, privateKey, + extraChain == null && previous != null ? previous.chain : extraChain)); return this; } @@ -561,22 +570,38 @@ private static class Credential { final String alias; final X509AuxCertificate leaf; final PrivateKey privateKey; - final String keyType; final List chain; // null => build from the cert store Credential(final String alias, final X509AuxCertificate leaf, final PrivateKey privateKey, - final String keyType, final List chain) { this.alias = alias; this.leaf = leaf; this.privateKey = privateKey; - this.keyType = keyType; this.chain = chain; } } + private static ASN1ObjectIdentifier keyAlgorithmId(final PublicKey publicKey) { + switch (publicKey.getAlgorithm().toUpperCase(Locale.ROOT)) { + case "RSA": + return PKCSObjectIdentifiers.rsaEncryption; + case "RSASSA-PSS": + return PKCSObjectIdentifiers.id_RSASSA_PSS; + case "EC": case "ECDSA": + return X9ObjectIdentifiers.id_ecPublicKey; + case "DSA": + return X9ObjectIdentifiers.id_dsa; + case "ED25519": + return EdECObjectIdentifiers.id_Ed25519; + case "ED448": + return EdECObjectIdentifiers.id_Ed448; + } + // NOTE: SunEC reports "EdDSA" for both Ed25519 and Ed448 types + return SubjectPublicKeyInfo.getInstance(publicKey.getEncoded()).getAlgorithm().getAlgorithm(); + } + @JRubyMethod public RubyArray ciphers(final ThreadContext context) { // SSL_CTX_get_ciphers return newSharedArray(matchedCiphersWithCache(context, this.ciphers).cipherList); @@ -1082,13 +1107,20 @@ static RubyClass _SSLContext(final Ruby runtime) { private List buildCredentials(final X509Cert cert, final PKey key, final List extraChainCert) { - if ( cert == null || key == null || key.getPrivateKey() == null || cert.getAuxCert() == null ) { - return addedCredentials; + final LinkedHashMap credentials = new LinkedHashMap<>(addedCredentials); + + if (cert != null && key != null && key.getPrivateKey() != null && cert.getAuxCert() != null) { + final X509AuxCertificate leaf = cert.getAuxCert(); + final PublicKey publicKey = key.getPublicKey(); + if (publicKey == null || !Arrays.equals(leaf.getPublicKey().getEncoded(), publicKey.getEncoded())) { + throw newSSLError(getRuntime(), "certificate and private key do not match"); + } + final String alias = keyAlgorithmId(leaf.getPublicKey()).getId(); + final Credential previous = credentials.get(alias); + credentials.put(alias, new Credential(alias, leaf, key.getPrivateKey(), + extraChainCert == null && previous != null ? previous.chain : extraChainCert)); } - final ArrayList credentials = new ArrayList<>(addedCredentials.size() + 1); - credentials.add(new Credential("@cert", cert.getAuxCert(), key.getPrivateKey(), key.getKeyType(), extraChainCert)); - credentials.addAll(addedCredentials); - return credentials; + return new ArrayList<>(credentials.values()); } private InternalContext initSSLContext(ThreadContext context, @@ -1106,7 +1138,7 @@ private InternalContext initSSLContext(ThreadContext context, ); internalContext.initSSLContext(context); } - catch (GeneralSecurityException | IllegalStateException e) { + catch (GeneralSecurityException | IOException | IllegalStateException e) { // IllegalStateException from BC random initialization: "RDSEED persistently failed to produce entropy" throw newSSLError(context.runtime, e); } @@ -1143,13 +1175,36 @@ private class InternalContext { this.sslContext = SecurityHelper.getSSLContext(protocol); } - void initSSLContext(final ThreadContext context) throws NoSuchAlgorithmException, KeyManagementException { - final KeyManager[] keyManager = new KeyManager[] { new KeyManagerImpl(this) }; + void initSSLContext(final ThreadContext context) throws GeneralSecurityException, IOException { + final LinkedHashMap entries = new LinkedHashMap<>(); + for (Credential credential : credentials) { + final X509Certificate[] chain = outputCertChain(credential); + PrivateKey privateKey = credential.privateKey; + final String algorithm = chain[0].getPublicKey().getAlgorithm(); + if (PKeyEdDSA.isEdDSAAlgorithm(algorithm) && "SunJSSE".equals(sslContext.getProvider().getName())) { + // both keys must report EdDSA to SunJSSE + final KeyFactory keyFactory = KeyFactory.getInstance("EdDSA", "SunEC"); + if (!"EdDSA".equals(privateKey.getAlgorithm())) { + privateKey = (PrivateKey) keyFactory.translateKey(privateKey); + } + if (!"EdDSA".equals(algorithm)) { + final PublicKey publicKey = (PublicKey) keyFactory.translateKey(chain[0].getPublicKey()); + // expose SunEC key without changing certificate bytes (SUN parsing can still pick BC) + chain[0] = new X509AuxCertificate(chain[0]) { + @Override + public PublicKey getPublicKey() { return publicKey; } + }; + } + } + entries.put(credential.alias, new SSLKeyStore.Entry(privateKey, chain)); + } + final KeyManagerFactory keyManager = KeyManagerFactory.getInstance("PKIX", sslContext.getProvider()); + keyManager.init(new SSLKeyStore(entries), null); final TrustManager[] trustManager = new TrustManager[] { new TrustManagerImpl(this) }; // SSLContext (internals) on Sun JDK : // private final java.security.Provider provider; "SunJSSE" // private final javax.net.ssl.SSLContextSpi; sun.security.ssl.SSLContextImpl - sslContext.init(keyManager, trustManager, OpenSSL.getSecureRandom(context)); + sslContext.init(keyManager.getKeyManagers(), trustManager, OpenSSL.getSecureRandom(context)); // if secureRandom == null JSSE will try : // - new SecureRandom(); // - SecureRandom.getInstance("PKCS11", cryptoProvider); @@ -1209,85 +1264,24 @@ void setLastVerifyResult(int lastVerifyResult) { SSLContext.this.setLastVerifyResult(lastVerifyResult); } - } - - private static class KeyManagerImpl extends X509ExtendedKeyManager { - - final InternalContext internalContext; - - KeyManagerImpl(InternalContext internalContext) { - super(); - this.internalContext = internalContext; - } - - @Override - public String chooseEngineClientAlias(String[] keyType, java.security.Principal[] issuers, javax.net.ssl.SSLEngine engine) { - for (int i = 0; i < keyType.length; i++) { - String alias = chooseAlias(keyType[i]); - if (alias != null) return alias; - } - return null; - } - - @Override - public String chooseEngineServerAlias(String keyType, java.security.Principal[] issuers, javax.net.ssl.SSLEngine engine) { - return chooseAlias(keyType); - } - - @Override - public String chooseClientAlias(String[] keyType, java.security.Principal[] issuers, java.net.Socket socket) { - for (int i = 0; i < keyType.length; i++) { - String alias = chooseAlias(keyType[i]); - if (alias != null) return alias; - } - return null; - } - - @Override - public String chooseServerAlias(String keyType, java.security.Principal[] issuers, java.net.Socket socket) { - return chooseAlias(keyType); - } - - // BCJSSE asks per negotiated key type (RSA / EC / ...) - 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)) { - return credential.alias; // pick the first credential that matches - } - } - return null; - } - - private Credential credentialByAlias(String alias) { - if (alias == null) return null; - for (int i = 0; i < internalContext.credentials.size(); i++) { - final Credential credential = internalContext.credentials.get(i); - if (credential.alias.equals(alias)) return credential; - } - return null; - } - - @Override // c: ssl3_output_cert_chain - public java.security.cert.X509Certificate[] getCertificateChain(String alias) { - final Credential credential = credentialByAlias(alias); - if ( credential == null || credential.leaf == null ) return new java.security.cert.X509Certificate[0]; - + // c: ssl3_output_cert_chain + private X509Certificate[] outputCertChain(final Credential credential) { if ( credential.chain != null ) { + final int chainLen = credential.chain.size(); // OpenSSL (ssl_add_cert_chain) sends the leaf cert first, then iterates extra_certs // getCertificateChain must return the full chain starting with the leaf - final java.security.cert.X509Certificate[] chain; - chain = new java.security.cert.X509Certificate[credential.chain.size() + 1]; + final X509Certificate[] chain = new X509Certificate[chainLen + 1]; chain[0] = credential.leaf; - for (int i = 0; i < credential.chain.size(); i++) chain[i + 1] = credential.chain.get(i); + for (int i = 0; i < chainLen; i++) chain[i + 1] = credential.chain.get(i); return chain; } - final ArrayList chain = new ArrayList<>(8); - StoreContext storeCtx = internalContext.createStoreContext(null); + final ArrayList chain = new ArrayList<>(8); + final StoreContext storeCtx = createStoreContext(null); + assert storeCtx != null; + X509AuxCertificate x = credential.leaf; - while (true) { + while (!chain.contains(x)) { chain.add(x); @@ -1302,31 +1296,15 @@ public java.security.cert.X509Certificate[] getCertificateChain(String alias) { x = ((Certificate) s_obj[0]).cert; } catch (RuntimeException e) { - LOG.debugStack(e); + LOG.debugStack(getRuntime(), "outputCertChain", e); break; } catch (Exception e) { - LOG.debug("KeyManagerImpl bySubject failed", e); + LOG.debug(getRuntime(), "outputCertChain", e); break; } } - return chain.toArray( new java.security.cert.X509Certificate[chain.size()] ); - } - - @Override - public String[] getClientAliases(String keyType, java.security.Principal[] issuers) { - return null; - } - - @Override - public java.security.PrivateKey getPrivateKey(String alias) { - final Credential credential = credentialByAlias(alias); - return credential == null ? null : credential.privateKey; - } - - @Override - public String[] getServerAliases(String keyType, java.security.Principal[] issuers) { - return null; + return chain.toArray(new X509Certificate[chain.size()]); } } diff --git a/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java new file mode 100644 index 00000000..6948ba35 --- /dev/null +++ b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Karol Bucek. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.jruby.ext.openssl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.Key; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreSpi; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.Enumeration; +import java.util.Map; + +/** + * Lets provider key managers handle TLS algorithms, issuers and security constraints, + * while preserving opaque and (FIPS) key objects without encoding or reconstructing. + * @author kares + */ +final class SSLKeyStore extends KeyStore { + + static final class Entry { + private final PrivateKey key; + private final X509Certificate[] chain; + + Entry(PrivateKey key, X509Certificate[] chain) { + this.key = key; + this.chain = chain; + } + } + + SSLKeyStore(Map entries) throws GeneralSecurityException, IOException { + super(new Store(entries), null, "JOSSL"); + load(null, null); // so that the store is initialized (doe spi.engineLoad) + } + + private static final class Store extends KeyStoreSpi { + private final Map entries; // we're using a LinkedHashMap + + Store(Map entries) { + this.entries = entries; + } + + @Override + public Key engineGetKey(String alias, char[] password) { + Entry entry = entries.get(alias); + return entry == null ? null : entry.key; + } + + @Override + public Certificate[] engineGetCertificateChain(String alias) { + Entry entry = entries.get(alias); + return entry == null ? null : entry.chain.clone(); + } + + @Override + public Certificate engineGetCertificate(String alias) { + Entry entry = entries.get(alias); + return entry == null ? null : entry.chain[0]; + } + + @Override + public Date engineGetCreationDate(String alias) { + return entries.containsKey(alias) ? new Date(0) : null; + } + + @Override + public Enumeration engineAliases() { + return Collections.enumeration(entries.keySet()); + } + + @Override + public boolean engineContainsAlias(String alias) { + return entries.containsKey(alias); + } + + @Override + public int engineSize() { + return entries.size(); + } + + @Override + public boolean engineIsKeyEntry(String alias) { + return entries.containsKey(alias); + } + + @Override + public boolean engineIsCertificateEntry(String alias) { + return false; + } + + @Override + public String engineGetCertificateAlias(Certificate cert) { + for (Map.Entry entry : entries.entrySet()) { + if (entry.getValue().chain[0].equals(cert)) return entry.getKey(); + } + return null; + } + + @Override + public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) { + throw new UnsupportedOperationException("read-only TLS credentials"); + } + + @Override + public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) { + throw new UnsupportedOperationException("read-only TLS credentials"); + } + + @Override + public void engineSetCertificateEntry(String alias, Certificate cert) { + throw new UnsupportedOperationException("read-only TLS credentials"); + } + + @Override + public void engineDeleteEntry(String alias) { + throw new UnsupportedOperationException("read-only TLS credentials"); + } + + @Override + public void engineStore(OutputStream stream, char[] password) { + throw new UnsupportedOperationException("read-only TLS credentials"); + } + + @Override + public void engineLoad(InputStream stream, char[] password) throws IOException { + if (stream != null) throw new IOException("read-only TLS credentials"); + } + + @Override + public void engineLoad(final LoadStoreParameter param) throws IOException { + engineLoad((InputStream) null, (char[]) null); + } + } +} diff --git a/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java new file mode 100644 index 00000000..f257fd28 --- /dev/null +++ b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java @@ -0,0 +1,166 @@ +package org.jruby.ext.openssl; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.Provider; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.security.interfaces.RSAPrivateKey; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.X509KeyManager; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +import static org.junit.jupiter.api.Assertions.*; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class SSLKeyStoreTest extends OpenSSLHelper { + + private RSAPrivateKey key; + private X509Certificate certificate; + + @BeforeAll + public void setUp() throws Exception { + setUpRuntime(); + runtime.evalScriptlet( + "$key = OpenSSL::PKey::RSA.new(2048)\n" + + "$cert = OpenSSL::X509::Certificate.new\n" + + "$cert.version = 2; $cert.serial = 1\n" + + "$cert.subject = $cert.issuer = OpenSSL::X509::Name.parse('/CN=TLS KeyStore Test')\n" + + "$cert.public_key = $key.public_key\n" + + "$cert.not_before = Time.now - 3600; $cert.not_after = Time.now + 3600\n" + + "$cert.sign($key, OpenSSL::Digest::SHA256.new)\n"); + key = new OpaqueRSAKey((RSAPrivateKey) ((PKey) runtime.evalScriptlet("$key")).getPrivateKey()); + certificate = ((X509Cert) runtime.evalScriptlet("$cert")).getAuxCert(); + } + + @AfterAll + public void tearDown() { + tearDownRuntime(); + } + + private SSLKeyStore keyStore() throws Exception { + return new SSLKeyStore(Collections.singletonMap("rsa", new SSLKeyStore.Entry( + key, new X509Certificate[] { certificate }))); + } + + @Test + public void preservesOpaquePrivateKey() throws Exception { + SSLKeyStore store = keyStore(); + assertSame(key, store.getKey("rsa", null)); + KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) store.getEntry( + "rsa", new KeyStore.PasswordProtection(new char[0])); + assertSame(key, entry.getPrivateKey()); + assertSame(certificate, entry.getCertificate()); + } + + @Test + public void nativeKeyManagersPreserveOpaquePrivateKey() throws Exception { + Provider[] providers = { SecurityHelper.getJsseProvider(), Security.getProvider("SunJSSE") }; + for (Provider provider : providers) { + assertNotNull(provider); + KeyManagerFactory factory = KeyManagerFactory.getInstance("PKIX", provider); + factory.init(keyStore(), null); + X509KeyManager manager = (X509KeyManager) factory.getKeyManagers()[0]; + String[] aliases = manager.getServerAliases("RSA", null); + assertNotNull(aliases, provider.getName()); + assertEquals(1, aliases.length, provider.getName()); + assertSame(key, manager.getPrivateKey(aliases[0]), provider.getName()); + assertArrayEquals(new Certificate[] { certificate }, manager.getCertificateChain(aliases[0])); + } + } + + @Test + public void snapshotsCredentialMapAndCertificateChain() throws Exception { + Map entries = new LinkedHashMap<>(); + X509Certificate[] chain = { certificate }; + entries.put("rsa", new SSLKeyStore.Entry(key, chain)); + SSLKeyStore store = new SSLKeyStore(entries); + + entries.clear(); + chain[0] = null; + + assertEquals(1, store.size()); + assertEquals(Collections.singletonList("rsa"), Collections.list(store.aliases())); + assertSame(key, store.getKey("rsa", null)); + assertSame(certificate, store.getCertificate("rsa")); + assertEquals("rsa", store.getCertificateAlias(certificate)); + assertTrue(store.isKeyEntry("rsa")); + assertFalse(store.isCertificateEntry("rsa")); + assertArrayEquals(new Certificate[] { certificate }, store.getCertificateChain("rsa")); + } + + @Test + public void returnedCertificateChainsAreIndependent() throws Exception { + SSLKeyStore store = keyStore(); + Certificate[] first = store.getCertificateChain("rsa"); + Certificate[] second = store.getCertificateChain("rsa"); + assertNotSame(first, second); + first[0] = null; + assertSame(certificate, second[0]); + assertSame(certificate, store.getCertificateChain("rsa")[0]); + } + + @Test + public void rejectsCredentialMutationAndPersistence() throws Exception { + SSLKeyStore store = keyStore(); + Certificate[] chain = { certificate }; + assertThrows(KeyStoreException.class, () -> store.setKeyEntry("new", key, null, chain)); + assertThrows(KeyStoreException.class, () -> store.setKeyEntry("new", new byte[] { 1 }, chain)); + assertThrows(KeyStoreException.class, () -> store.setCertificateEntry("new", certificate)); + assertThrows(KeyStoreException.class, () -> store.deleteEntry("rsa")); + assertThrows(KeyStoreException.class, + () -> store.setEntry("new", new KeyStore.PrivateKeyEntry(key, chain), null)); + assertThrows(IOException.class, () -> store.store(new ByteArrayOutputStream(), null)); + assertThrows(IOException.class, () -> store.load(new ByteArrayInputStream(new byte[0]), null)); + assertEquals(1, store.size()); + assertSame(key, store.getKey("rsa", null)); + assertSame(certificate, store.getCertificate("rsa")); + } + + @Test + public void missingAliasesReturnNull() throws Exception { + SSLKeyStore store = keyStore(); + assertNull(store.getKey("missing", null)); + assertNull(store.getEntry("missing", null)); + assertNull(store.getCertificate("missing")); + assertNull(store.getCertificateChain("missing")); + assertNull(store.getCreationDate("missing")); + assertFalse(store.containsAlias("missing")); + assertFalse(store.isKeyEntry("missing")); + assertFalse(store.isCertificateEntry("missing")); + } + + private static final class OpaqueRSAKey implements RSAPrivateKey { + private final RSAPrivateKey key; + + OpaqueRSAKey(RSAPrivateKey key) { this.key = key; } + + @Override + public BigInteger getPrivateExponent() { return key.getPrivateExponent(); } + + @Override + public BigInteger getModulus() { return key.getModulus(); } + + @Override + public String getAlgorithm() { return key.getAlgorithm(); } + + @Override + public String getFormat() { return null; } + + @Override + public byte[] getEncoded() { throw new AssertionError("opaque key must not be encoded"); } + } +} diff --git a/test/ssl/test_credentials.rb b/test/ssl/test_credentials.rb new file mode 100644 index 00000000..c93cb7df --- /dev/null +++ b/test/ssl/test_credentials.rb @@ -0,0 +1,276 @@ +require File.expand_path('test_helper', File.dirname(__FILE__)) +require 'timeout' + +class TestSSLCredentials < TestCase + include SSLTestHelper + + VERSIONS = { 'tls12' => ['TLSv1_2', 'TLSv1.2'], 'tls13' => ['TLSv1_3', 'TLSv1.3'] } + KEY_TYPES = %w[rsa p256 p384 ed25519] + + KEY_TYPES.each do |key_type| + VERSIONS.each do |version, (context_version, negotiated_version)| + %w[legacy add_certificate].each do |registration| + define_method("test_server_#{key_type}_#{version}_#{registration}") do + check_support(key_type, context_version) + key, cert = credential(key_type) + configure = proc do |ctx| + set_version(ctx, context_version) + install_credential(ctx, registration, cert, key) + end + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + server_connect(port, client_context(context_version)) do |ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_peer_credential(ssl, cert) + assert_equal [cert.to_der, @ca_cert.to_der], ssl.peer_cert_chain.map(&:to_der) + assert_echo(ssl) + end + end + end + end + end + end + + def test_tls12_selects_rsa_and_ec_credentials_by_cipher + check_support('p256', 'TLSv1_2') + ec_key, ec_cert = credential('p256') + configure = proc do |ctx| + set_version(ctx, 'TLSv1_2') + ctx.extra_chain_cert = [@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-RSA-AES128-GCM-SHA256' => @svr_cert, + 'ECDHE-ECDSA-AES128-GCM-SHA256' => ec_cert }.each do |cipher, cert| + ctx = client_context('TLSv1_2') + ctx.ciphers = cipher + server_connect(port, ctx) do |ssl| + assert_equal 'TLSv1.2', ssl.ssl_version + assert_equal cipher, ssl.cipher.first + assert_peer_credential(ssl, cert) + assert_equal [cert.to_der, @ca_cert.to_der], ssl.peer_cert_chain.map(&:to_der) + assert_echo(ssl) + end + end + end + end + + def test_legacy_credential_rejects_mismatched_private_key + ctx = OpenSSL::SSL::SSLContext.new + ctx.cert = @svr_cert + ctx.key = @ca_key + assert_raise(OpenSSL::SSL::SSLError) { ctx.setup } + end + + def test_circular_certificate_store_chain_setup_terminates + ca_extensions = [['basicConstraints', 'CA:TRUE', true], ['keyUsage', 'keyCertSign', true]] + a_name = OpenSSL::X509::Name.parse('/CN=CA A') + b_name = OpenSSL::X509::Name.parse('/CN=CA B') + b_cert = issue_cert(b_name, @svr_key, 40, ca_extensions, nil, nil) + a_cert = issue_cert(a_name, @ca_key, 41, ca_extensions, b_cert, @svr_key) + b_cert = issue_cert(b_name, @svr_key, 42, ca_extensions, a_cert, @ca_key) + leaf = issue_cert(@svr, @svr_key, 43, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', 'serverAuth', false]], a_cert, @ca_key) + store = OpenSSL::X509::Store.new + store.add_cert(a_cert) + store.add_cert(b_cert) + ctx = OpenSSL::SSL::SSLContext.new + ctx.cert = leaf + ctx.key = @svr_key + ctx.cert_store = store + assert_equal true, Timeout.timeout(5) { ctx.setup } + end + + VERSIONS.each do |version, (context_version, negotiated_version)| + %w[omitted empty].each do |replacement_chain| + define_method("test_replacement_#{replacement_chain}_chain_#{version}") do + check_support('rsa', context_version) + first_key, first_cert = credential('rsa', 50) + second_key, second_cert = credential('rsa', 51) + configure = proc do |ctx| + set_version(ctx, context_version) + install_credential(ctx, 'add_certificate', first_cert, first_key) + if replacement_chain == 'omitted' + ctx.add_certificate(second_cert, second_key) + else + ctx.add_certificate(second_cert, second_key, []) + end + end + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + server_connect(port, client_context(context_version)) do |ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_peer_credential(ssl, second_cert) + expected_chain = [second_cert.to_der] + expected_chain << @ca_cert.to_der if replacement_chain == 'omitted' + assert_equal expected_chain, ssl.peer_cert_chain.map(&:to_der) + assert_echo(ssl) + end + end + end + end + + %w[rsa p256].each do |key_type| + define_method("test_last_#{key_type}_credential_replaces_previous_#{version}") do + check_support(key_type, context_version) + first_key, first_cert = credential(key_type, 10) + second_key, second_cert = credential(key_type == 'p256' ? 'p384' : 'rsa', 11) + configure = proc do |ctx| + set_version(ctx, context_version) + install_credential(ctx, 'add_certificate', first_cert, first_key) + ctx.add_certificate(second_cert, second_key, [@ca_cert]) + end + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + server_connect(port, client_context(context_version)) do |ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_peer_credential(ssl, second_cert) + assert_equal [second_cert.to_der, @ca_cert.to_der], ssl.peer_cert_chain.map(&:to_der) + assert_echo(ssl) + end + end + end + + define_method("test_legacy_#{key_type}_credential_replaces_added_#{version}") do + check_support(key_type, context_version) + first_key, first_cert = credential(key_type, 12) + second_key, second_cert = credential(key_type, 13) + configure = proc do |ctx| + set_version(ctx, context_version) + ctx.add_certificate(first_cert, first_key, [@ca_cert]) + ctx.cert = second_cert + ctx.key = second_key + ctx.extra_chain_cert = [@ca_cert] + end + start_server(OpenSSL::SSL::VERIFY_NONE, true, ctx_proc: configure) do |_server, port| + server_connect(port, client_context(context_version)) do |ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_peer_credential(ssl, second_cert) + assert_echo(ssl) + end + end + end + end + + %w[rsa p256].each do |key_type| + %w[legacy add_certificate].each do |registration| + define_method("test_client_auth_#{key_type}_#{version}_#{registration}") do + check_support(key_type, context_version) + key, cert = credential(key_type, 20, 'clientAuth') + configure = proc do |ctx| + set_version(ctx, context_version) + ctx.client_ca = [@ca_cert] + end + inspect_client = proc do |_ctx, ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_peer_credential(ssl, cert) + assert_equal [cert.to_der, @ca_cert.to_der], ssl.peer_cert_chain.map(&:to_der) if jruby? + ssl.puts ssl.gets + end + verify = OpenSSL::SSL::VERIFY_PEER | OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT + start_server(verify, true, ctx_proc: configure, server_proc: inspect_client) do |_server, port| + ctx = client_context(context_version) + install_credential(ctx, registration, cert, key) + server_connect(port, ctx) do |ssl| + assert_equal negotiated_version, ssl.ssl_version + assert_echo(ssl) + end + end + end + end + end + + define_method("test_client_credential_with_nonmatching_issuer_#{version}") do + skip 'JSSE filters client certificate issuers' unless jruby? + check_support('rsa', context_version) + key, cert = credential('rsa', 30, 'clientAuth') + other_ca = issue_cert(OpenSSL::X509::Name.parse('/CN=Other CA'), @ca_key, 31, + [['basicConstraints', 'CA:TRUE', true], ['keyUsage', 'keyCertSign', true]], nil, nil) + configure = proc do |ctx| + set_version(ctx, context_version) + ctx.client_ca = [other_ca] + end + inspect_client = proc do |_ctx, ssl| + assert_nil ssl.peer_cert + ssl.puts ssl.gets + end + start_server(OpenSSL::SSL::VERIFY_PEER, true, ctx_proc: configure, + server_proc: inspect_client) do |_server, port| + ctx = client_context(context_version) + ctx.add_certificate(cert, key, [@ca_cert]) + server_connect(port, ctx) { |ssl| assert_echo(ssl) } + end + end + end + + private + + def set_version(ctx, version) + if !jruby? && version == 'TLSv1_3' + ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_3_VERSION + else + ctx.ssl_version = version + end + end + + def check_support(key_type, version) + sunjsse = jruby? && java.lang.System.getProperty('jruby.openssl.provider.ssl') == 'false' + java_version = self.class.java_version.last.to_i if jruby? + skip 'SunJSSE requires Java 11 for TLS 1.3' if sunjsse && version == 'TLSv1_3' && java_version < 11 + if key_type == 'ed25519' + omit_on_fips + skip 'SunJSSE requires Java 15 for Ed25519' if sunjsse && java_version < 15 + end + if fips? && version == 'TLSv1_2' && key_type.start_with?('p') + ctx = OpenSSL::SSL::SSLContext.new + set_version(ctx, version) + unless ctx.ciphers.any? { |cipher| cipher.first == 'ECDHE-ECDSA-AES128-GCM-SHA256' } + skip 'ECDHE-ECDSA is unavailable in approved mode' + end + end + end + + def credential(key_type, serial = 4, purpose = 'serverAuth') + key = case key_type + when 'rsa' then @svr_key + when 'p256' then OpenSSL::PKey::EC.generate('prime256v1') + when 'p384' then OpenSSL::PKey::EC.generate('secp384r1') + when 'ed25519' then OpenSSL::PKey.generate_key('ED25519') + end + cert = issue_cert(OpenSSL::X509::Name.parse("/CN=#{key_type}-#{serial}"), key, serial, + [['keyUsage', 'digitalSignature', true], ['extendedKeyUsage', purpose, false]], @ca_cert, @ca_key) + [key, cert] + end + + def install_credential(ctx, registration, cert, key) + ctx.cert = ctx.key = ctx.extra_chain_cert = 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 + + def client_context(version) + ctx = OpenSSL::SSL::SSLContext.new + set_version(ctx, version) + ctx.cert_store = OpenSSL::X509::Store.new + ctx.cert_store.add_cert(@ca_cert) + ctx.verify_mode = OpenSSL::SSL::VERIFY_PEER + ctx + end + + def assert_peer_credential(ssl, cert) + assert_equal cert.subject.to_der, ssl.peer_cert.subject.to_der + assert_equal cert.public_key.oid, ssl.peer_cert.public_key.oid + assert_equal cert.public_key.public_to_der, ssl.peer_cert.public_key.public_to_der + if cert.public_key.oid == 'ED25519' + assert_equal cert.public_key.raw_public_key, ssl.peer_cert.public_key.raw_public_key + end + assert_equal cert.to_der, ssl.peer_cert.to_der + end + + def assert_echo(ssl) + ssl.puts 'credential echo' + assert_equal "credential echo\n", ssl.gets + end +end From db4c28de0f1d562c7865cbf45663350e3d4b7981 Mon Sep 17 00:00:00 2001 From: kares Date: Fri, 23 Oct 2026 14:10:20 +0200 Subject: [PATCH 2/4] refactor: simplify and use KeyStore inner class --- .../org/jruby/ext/openssl/SSLContext.java | 68 +++++++++---------- .../org/jruby/ext/openssl/SSLKeyStore.java | 34 +++------- .../jruby/ext/openssl/SSLKeyStoreTest.java | 6 +- 3 files changed, 48 insertions(+), 60 deletions(-) diff --git a/src/main/java/org/jruby/ext/openssl/SSLContext.java b/src/main/java/org/jruby/ext/openssl/SSLContext.java index 4e19bfa1..f7651634 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLContext.java +++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java @@ -40,6 +40,7 @@ import java.security.GeneralSecurityException; import java.security.KeyFactory; +import java.security.KeyStore; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; @@ -59,8 +60,8 @@ import org.bouncycastle.asn1.edec.EdECObjectIdentifiers; import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; +import org.bouncycastle.asn1.x9.X9ObjectIdentifiers; import org.jruby.Ruby; import org.jruby.RubyArray; import org.jruby.RubyClass; @@ -558,31 +559,37 @@ public IRubyObject add_certificate(final ThreadContext context, final IRubyObjec extraChain = null; } - final String alias = keyAlgorithmId(certPublicKey).getId(); - final Credential previous = addedCredentials.get(alias); - addedCredentials.put(alias, new Credential(alias, auxCert, privateKey, - extraChain == null && previous != null ? previous.chain : extraChain)); + putCredential(addedCredentials, auxCert, privateKey, extraChain); return this; } private static class Credential { - final String alias; final X509AuxCertificate leaf; final PrivateKey privateKey; final List chain; // null => build from the cert store - Credential(final String alias, - final X509AuxCertificate leaf, + Credential(final X509AuxCertificate leaf, final PrivateKey privateKey, final List chain) { - this.alias = alias; this.leaf = leaf; this.privateKey = privateKey; this.chain = chain; } } + // one credential per key algorithm OID (all EC curves share a slot - like in OpenSSL) + private static void putCredential(final Map credentials, + final X509AuxCertificate leaf, + final PrivateKey privateKey, + final List chain) { + final String alias = keyAlgorithmId(leaf.getPublicKey()).getId(); + final Credential previous = credentials.get(alias); + // a replaced certificate keeps its slot's chain unless a new one is given + final List effectiveChain = chain == null && previous != null ? previous.chain : chain; + credentials.put(alias, new Credential(leaf, privateKey, effectiveChain)); + } + private static ASN1ObjectIdentifier keyAlgorithmId(final PublicKey publicKey) { switch (publicKey.getAlgorithm().toUpperCase(Locale.ROOT)) { case "RSA": @@ -1105,9 +1112,9 @@ static RubyClass _SSLContext(final Ruby runtime) { return (RubyClass) _SSL(runtime).getConstantAt("SSLContext"); } - private List buildCredentials(final X509Cert cert, final PKey key, - final List extraChainCert) { - final LinkedHashMap credentials = new LinkedHashMap<>(addedCredentials); + private Map buildCredentials(final X509Cert cert, final PKey key, + final List extraChainCert) { + final Map credentials = new LinkedHashMap<>(addedCredentials); if (cert != null && key != null && key.getPrivateKey() != null && cert.getAuxCert() != null) { final X509AuxCertificate leaf = cert.getAuxCert(); @@ -1115,31 +1122,28 @@ private List buildCredentials(final X509Cert cert, final PKey key, if (publicKey == null || !Arrays.equals(leaf.getPublicKey().getEncoded(), publicKey.getEncoded())) { throw newSSLError(getRuntime(), "certificate and private key do not match"); } - final String alias = keyAlgorithmId(leaf.getPublicKey()).getId(); - final Credential previous = credentials.get(alias); - credentials.put(alias, new Credential(alias, leaf, key.getPrivateKey(), - extraChainCert == null && previous != null ? previous.chain : extraChainCert)); + putCredential(credentials, leaf, key.getPrivateKey(), extraChainCert); } - return new ArrayList<>(credentials.values()); + return credentials; } private InternalContext initSSLContext(ThreadContext context, final Store store, final List clientCA, final int verifyMode, final IRubyObject verifyCallback, final int verifyDepth, final int timeout, - final String[] alpnProtocols, final RubyProc alpnSelectCb, final List certsAndKeys) { + final String[] alpnProtocols, final RubyProc alpnSelectCb, final Map credentials) { final InternalContext internalContext; try { internalContext = new InternalContext( store, clientCA, verifyMode, verifyCallback, verifyDepth, - timeout, alpnProtocols, alpnSelectCb, - certsAndKeys + timeout, alpnProtocols, alpnSelectCb ); - internalContext.initSSLContext(context); + internalContext.initSSLContext(context, credentials); } - catch (GeneralSecurityException | IOException | IllegalStateException e) { - // IllegalStateException from BC random initialization: "RDSEED persistently failed to produce entropy" + catch (GeneralSecurityException | IOException | IllegalStateException | IllegalArgumentException e) { + // IllegalStateException - BC random initialization: "RDSEED persistently failed to produce entropy" + // IllegalArgumentException - PrivateKeyEntry: key and certificate algorithm names differ throw newSSLError(context.runtime, e); } return internalContext; @@ -1158,10 +1162,7 @@ private class InternalContext { final int verifyDepth, final int timeout, final String[] alpnProtocols, - final RubyProc alpnSelectCallback, - final List certsAndKeys) throws NoSuchAlgorithmException { - - this.credentials = certsAndKeys; + final RubyProc alpnSelectCallback) throws NoSuchAlgorithmException { this.store = store; this.clientCA = clientCA; @@ -1175,9 +1176,11 @@ private class InternalContext { this.sslContext = SecurityHelper.getSSLContext(protocol); } - void initSSLContext(final ThreadContext context) throws GeneralSecurityException, IOException { - final LinkedHashMap entries = new LinkedHashMap<>(); - for (Credential credential : credentials) { + void initSSLContext(final ThreadContext context, final Map credentials) + throws GeneralSecurityException, IOException { + final Map entries = new LinkedHashMap<>(); + for (Map.Entry entry : credentials.entrySet()) { + final Credential credential = entry.getValue(); final X509Certificate[] chain = outputCertChain(credential); PrivateKey privateKey = credential.privateKey; final String algorithm = chain[0].getPublicKey().getAlgorithm(); @@ -1196,7 +1199,7 @@ void initSSLContext(final ThreadContext context) throws GeneralSecurityException }; } } - entries.put(credential.alias, new SSLKeyStore.Entry(privateKey, chain)); + entries.put(entry.getKey(), new KeyStore.PrivateKeyEntry(privateKey, chain)); } final KeyManagerFactory keyManager = KeyManagerFactory.getInstance("PKIX", sslContext.getProvider()); keyManager.init(new SSLKeyStore(entries), null); @@ -1231,9 +1234,6 @@ void initSSLContext(final ThreadContext context) throws GeneralSecurityException final IRubyObject verifyCallback; // null when unset final int verifyDepth; // -1 when unset - // one or more server/client credentials; empty when no cert/key configured - final List credentials; - // @client_ca - CAs whose names go into the CertificateRequest message final List clientCA; // assumed always != null diff --git a/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java index 6948ba35..0e1f0697 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java +++ b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java @@ -14,9 +14,7 @@ import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreSpi; -import java.security.PrivateKey; import java.security.cert.Certificate; -import java.security.cert.X509Certificate; import java.util.Collections; import java.util.Date; import java.util.Enumeration; @@ -29,44 +27,34 @@ */ final class SSLKeyStore extends KeyStore { - static final class Entry { - private final PrivateKey key; - private final X509Certificate[] chain; - - Entry(PrivateKey key, X509Certificate[] chain) { - this.key = key; - this.chain = chain; - } - } - - SSLKeyStore(Map entries) throws GeneralSecurityException, IOException { + SSLKeyStore(Map entries) throws GeneralSecurityException, IOException { super(new Store(entries), null, "JOSSL"); load(null, null); // so that the store is initialized (doe spi.engineLoad) } private static final class Store extends KeyStoreSpi { - private final Map entries; // we're using a LinkedHashMap + private final Map entries; // we're using a LinkedHashMap - Store(Map entries) { + Store(Map entries) { this.entries = entries; } @Override public Key engineGetKey(String alias, char[] password) { - Entry entry = entries.get(alias); - return entry == null ? null : entry.key; + PrivateKeyEntry entry = entries.get(alias); + return entry == null ? null : entry.getPrivateKey(); } @Override public Certificate[] engineGetCertificateChain(String alias) { - Entry entry = entries.get(alias); - return entry == null ? null : entry.chain.clone(); + PrivateKeyEntry entry = entries.get(alias); + return entry == null ? null : entry.getCertificateChain(); } @Override public Certificate engineGetCertificate(String alias) { - Entry entry = entries.get(alias); - return entry == null ? null : entry.chain[0]; + PrivateKeyEntry entry = entries.get(alias); + return entry == null ? null : entry.getCertificate(); } @Override @@ -101,8 +89,8 @@ public boolean engineIsCertificateEntry(String alias) { @Override public String engineGetCertificateAlias(Certificate cert) { - for (Map.Entry entry : entries.entrySet()) { - if (entry.getValue().chain[0].equals(cert)) return entry.getKey(); + for (Map.Entry entry : entries.entrySet()) { + if (entry.getValue().getCertificate().equals(cert)) return entry.getKey(); } return null; } diff --git a/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java index f257fd28..df207226 100644 --- a/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java +++ b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java @@ -52,7 +52,7 @@ public void tearDown() { } private SSLKeyStore keyStore() throws Exception { - return new SSLKeyStore(Collections.singletonMap("rsa", new SSLKeyStore.Entry( + return new SSLKeyStore(Collections.singletonMap("rsa", new KeyStore.PrivateKeyEntry( key, new X509Certificate[] { certificate }))); } @@ -84,9 +84,9 @@ public void nativeKeyManagersPreserveOpaquePrivateKey() throws Exception { @Test public void snapshotsCredentialMapAndCertificateChain() throws Exception { - Map entries = new LinkedHashMap<>(); + Map entries = new LinkedHashMap<>(); X509Certificate[] chain = { certificate }; - entries.put("rsa", new SSLKeyStore.Entry(key, chain)); + entries.put("rsa", new KeyStore.PrivateKeyEntry(key, chain)); SSLKeyStore store = new SSLKeyStore(entries); entries.clear(); From 0844672c11c65b14e43ee2f8298e971e624cf495 Mon Sep 17 00:00:00 2001 From: kares Date: Fri, 23 Oct 2026 14:56:49 +0200 Subject: [PATCH 3/4] refactor and handle extra-chain properly --- .../org/jruby/ext/openssl/SSLContext.java | 72 +++++++++++++------ 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/jruby/ext/openssl/SSLContext.java b/src/main/java/org/jruby/ext/openssl/SSLContext.java index f7651634..0ba481b5 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLContext.java +++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java @@ -29,6 +29,7 @@ import java.net.Socket; import java.io.IOException; +import java.security.InvalidKeyException; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; @@ -42,6 +43,7 @@ import java.security.KeyFactory; import java.security.KeyStore; import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.cert.CertificateException; @@ -49,7 +51,6 @@ import java.util.Arrays; import java.util.Collections; -import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLParameters; import javax.net.ssl.SSLSessionContext; @@ -1118,11 +1119,21 @@ private Map buildCredentials(final X509Cert cert, final PKey if (cert != null && key != null && key.getPrivateKey() != null && cert.getAuxCert() != null) { final X509AuxCertificate leaf = cert.getAuxCert(); + final PublicKey certPublicKey = leaf.getPublicKey(); final PublicKey publicKey = key.getPublicKey(); - if (publicKey == null || !Arrays.equals(leaf.getPublicKey().getEncoded(), publicKey.getEncoded())) { + if (certPublicKey == null || publicKey == null || + !Arrays.equals(certPublicKey.getEncoded(), publicKey.getEncoded())) { throw newSSLError(getRuntime(), "certificate and private key do not match"); } - putCredential(credentials, leaf, key.getPrivateKey(), extraChainCert); + putCredential(credentials, leaf, key.getPrivateKey(), null); + } + if (extraChainCert != null) { // explicit add_certificate chains take priority over shared chain + for (Map.Entry entry : credentials.entrySet()) { + final Credential credential = entry.getValue(); + if (credential.chain == null) { + entry.setValue(new Credential(credential.leaf, credential.privateKey, extraChainCert)); + } + } } return credentials; } @@ -1178,26 +1189,17 @@ private class InternalContext { void initSSLContext(final ThreadContext context, final Map credentials) throws GeneralSecurityException, IOException { - final Map entries = new LinkedHashMap<>(); + + final LinkedHashMap entries = new LinkedHashMap<>(); + final boolean usingSunProvider = "SunJSSE".equals(sslContext.getProvider().getName()); + for (Map.Entry entry : credentials.entrySet()) { final Credential credential = entry.getValue(); final X509Certificate[] chain = outputCertChain(credential); PrivateKey privateKey = credential.privateKey; - final String algorithm = chain[0].getPublicKey().getAlgorithm(); - if (PKeyEdDSA.isEdDSAAlgorithm(algorithm) && "SunJSSE".equals(sslContext.getProvider().getName())) { - // both keys must report EdDSA to SunJSSE - final KeyFactory keyFactory = KeyFactory.getInstance("EdDSA", "SunEC"); - if (!"EdDSA".equals(privateKey.getAlgorithm())) { - privateKey = (PrivateKey) keyFactory.translateKey(privateKey); - } - if (!"EdDSA".equals(algorithm)) { - final PublicKey publicKey = (PublicKey) keyFactory.translateKey(chain[0].getPublicKey()); - // expose SunEC key without changing certificate bytes (SUN parsing can still pick BC) - chain[0] = new X509AuxCertificate(chain[0]) { - @Override - public PublicKey getPublicKey() { return publicKey; } - }; - } + if (usingSunProvider) { + privateKey = translateKeysForSunEngineIfNecessary(chain, chain[0].getPublicKey(), privateKey); + if (privateKey == null) continue; // NOTE: key type not supported - best we can do is to skip? } entries.put(entry.getKey(), new KeyStore.PrivateKeyEntry(privateKey, chain)); } @@ -1228,6 +1230,33 @@ void initSSLContext(final ThreadContext context, final Map c } } + private PrivateKey translateKeysForSunEngineIfNecessary(final X509Certificate[] chain, + final PublicKey publicKey, + PrivateKey privateKey) throws InvalidKeyException { + if (PKeyEdDSA.isEdDSAAlgorithm(publicKey.getAlgorithm())) { + final KeyFactory keyFactory; + try { + keyFactory = KeyFactory.getInstance("EdDSA", "SunEC"); + } catch (NoSuchAlgorithmException | NoSuchProviderException e) { + return null; // older SunJSSE can still use other configured credential types + } + + // both keys must report EdDSA to SunJSSE + if (!"EdDSA".equals(privateKey.getAlgorithm())) { + privateKey = (PrivateKey) keyFactory.translateKey(privateKey); + } + if (!"EdDSA".equals(publicKey.getAlgorithm())) { + final PublicKey newPublicKey = (PublicKey) keyFactory.translateKey(publicKey); + // expose SunEC key without changing certificate bytes (SUN parsing can still pick BC) + chain[0] = new X509AuxCertificate(chain[0]) { + @Override + public PublicKey getPublicKey() { return newPublicKey; } + }; + } + } + return privateKey; // publicKey is replaced in the chain directly + } + final Store store; final int verifyMode; @@ -1276,10 +1305,11 @@ private X509Certificate[] outputCertChain(final Credential credential) { return chain; } - final ArrayList chain = new ArrayList<>(8); final StoreContext storeCtx = createStoreContext(null); - assert storeCtx != null; + // store may be absent or fail init - still send the configured leaf + if (storeCtx == null) return new X509Certificate[] { credential.leaf }; + final ArrayList chain = new ArrayList<>(8); X509AuxCertificate x = credential.leaf; while (!chain.contains(x)) { From cdab8389963247a654e9e6efd1e6b948e615c1fb Mon Sep 17 00:00:00 2001 From: kares Date: Fri, 23 Oct 2026 15:19:48 +0200 Subject: [PATCH 4/4] adjust key-store tests --- .../org/jruby/ext/openssl/SSLKeyStore.java | 28 ++++++++++--------- .../jruby/ext/openssl/SSLKeyStoreTest.java | 24 ++-------------- 2 files changed, 17 insertions(+), 35 deletions(-) diff --git a/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java index 0e1f0697..e74e1f06 100644 --- a/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java +++ b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java @@ -13,11 +13,13 @@ import java.security.Key; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.KeyStoreSpi; import java.security.cert.Certificate; import java.util.Collections; import java.util.Date; import java.util.Enumeration; +import java.util.LinkedHashMap; import java.util.Map; /** @@ -29,11 +31,11 @@ final class SSLKeyStore extends KeyStore { SSLKeyStore(Map entries) throws GeneralSecurityException, IOException { super(new Store(entries), null, "JOSSL"); - load(null, null); // so that the store is initialized (doe spi.engineLoad) + load(null, null); // marks the store initialized through spi.engineLoad } private static final class Store extends KeyStoreSpi { - private final Map entries; // we're using a LinkedHashMap + private final Map entries; Store(Map entries) { this.entries = entries; @@ -96,23 +98,23 @@ public String engineGetCertificateAlias(Certificate cert) { } @Override - public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) { - throw new UnsupportedOperationException("read-only TLS credentials"); + public void engineSetKeyEntry(String alias, Key key, char[] password, Certificate[] chain) throws KeyStoreException { + throw new KeyStoreException("read-only TLS credentials"); } @Override - public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) { - throw new UnsupportedOperationException("read-only TLS credentials"); + public void engineSetKeyEntry(String alias, byte[] key, Certificate[] chain) throws KeyStoreException { + throw new KeyStoreException("read-only TLS credentials"); } @Override - public void engineSetCertificateEntry(String alias, Certificate cert) { - throw new UnsupportedOperationException("read-only TLS credentials"); + public void engineSetCertificateEntry(String alias, Certificate cert) throws KeyStoreException { + throw new KeyStoreException("read-only TLS credentials"); } @Override - public void engineDeleteEntry(String alias) { - throw new UnsupportedOperationException("read-only TLS credentials"); + public void engineDeleteEntry(String alias) throws KeyStoreException { + throw new KeyStoreException("read-only TLS credentials"); } @Override @@ -121,12 +123,12 @@ public void engineStore(OutputStream stream, char[] password) { } @Override - public void engineLoad(InputStream stream, char[] password) throws IOException { - if (stream != null) throw new IOException("read-only TLS credentials"); + public void engineLoad(InputStream stream, char[] password) { + if (stream != null) throw new UnsupportedOperationException("read-only TLS credentials"); } @Override - public void engineLoad(final LoadStoreParameter param) throws IOException { + public void engineLoad(final LoadStoreParameter param) { engineLoad((InputStream) null, (char[]) null); } } diff --git a/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java index df207226..3fc72d94 100644 --- a/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java +++ b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java @@ -82,26 +82,6 @@ public void nativeKeyManagersPreserveOpaquePrivateKey() throws Exception { } } - @Test - public void snapshotsCredentialMapAndCertificateChain() throws Exception { - Map entries = new LinkedHashMap<>(); - X509Certificate[] chain = { certificate }; - entries.put("rsa", new KeyStore.PrivateKeyEntry(key, chain)); - SSLKeyStore store = new SSLKeyStore(entries); - - entries.clear(); - chain[0] = null; - - assertEquals(1, store.size()); - assertEquals(Collections.singletonList("rsa"), Collections.list(store.aliases())); - assertSame(key, store.getKey("rsa", null)); - assertSame(certificate, store.getCertificate("rsa")); - assertEquals("rsa", store.getCertificateAlias(certificate)); - assertTrue(store.isKeyEntry("rsa")); - assertFalse(store.isCertificateEntry("rsa")); - assertArrayEquals(new Certificate[] { certificate }, store.getCertificateChain("rsa")); - } - @Test public void returnedCertificateChainsAreIndependent() throws Exception { SSLKeyStore store = keyStore(); @@ -123,8 +103,8 @@ public void rejectsCredentialMutationAndPersistence() throws Exception { assertThrows(KeyStoreException.class, () -> store.deleteEntry("rsa")); assertThrows(KeyStoreException.class, () -> store.setEntry("new", new KeyStore.PrivateKeyEntry(key, chain), null)); - assertThrows(IOException.class, () -> store.store(new ByteArrayOutputStream(), null)); - assertThrows(IOException.class, () -> store.load(new ByteArrayInputStream(new byte[0]), null)); + assertThrows(Exception.class, () -> store.store(new ByteArrayOutputStream(), null)); + assertThrows(Exception.class, () -> store.load(new ByteArrayInputStream(new byte[0]), null)); assertEquals(1, store.size()); assertSame(key, store.getKey("rsa", null)); assertSame(certificate, store.getCertificate("rsa"));