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..0ba481b5 100644
--- a/src/main/java/org/jruby/ext/openssl/SSLContext.java
+++ b/src/main/java/org/jruby/ext/openssl/SSLContext.java
@@ -28,17 +28,22 @@
package org.jruby.ext.openssl;
import java.net.Socket;
+import java.io.IOException;
+import java.security.InvalidKeyException;
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.KeyStore;
import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.cert.CertificateException;
@@ -46,14 +51,18 @@
import java.util.Arrays;
import java.util.Collections;
-import javax.net.ssl.KeyManager;
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 +341,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,32 +560,56 @@ 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));
+ putCredential(addedCredentials, auxCert, privateKey, extraChain);
return this;
}
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,
+ Credential(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;
}
}
+ // 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":
+ 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);
@@ -1080,34 +1113,48 @@ static RubyClass _SSLContext(final Ruby runtime) {
return (RubyClass) _SSL(runtime).getConstantAt("SSLContext");
}
- 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;
+ 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();
+ final PublicKey certPublicKey = leaf.getPublicKey();
+ final PublicKey publicKey = key.getPublicKey();
+ 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(), 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));
+ }
+ }
}
- 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;
}
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 | 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;
@@ -1126,10 +1173,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;
@@ -1143,13 +1187,29 @@ 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, final Map credentials)
+ throws GeneralSecurityException, IOException {
+
+ 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;
+ 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));
+ }
+ 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);
@@ -1170,15 +1230,39 @@ void initSSLContext(final ThreadContext context) throws NoSuchAlgorithmException
}
}
+ 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;
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
@@ -1209,85 +1293,25 @@ 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 StoreContext storeCtx = createStoreContext(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 (true) {
+ while (!chain.contains(x)) {
chain.add(x);
@@ -1302,31 +1326,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..e74e1f06
--- /dev/null
+++ b/src/main/java/org/jruby/ext/openssl/SSLKeyStore.java
@@ -0,0 +1,135 @@
+/*
+ * 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.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;
+
+/**
+ * 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 {
+
+ SSLKeyStore(Map entries) throws GeneralSecurityException, IOException {
+ super(new Store(entries), null, "JOSSL");
+ load(null, null); // marks the store initialized through spi.engineLoad
+ }
+
+ private static final class Store extends KeyStoreSpi {
+ private final Map entries;
+
+ Store(Map entries) {
+ this.entries = entries;
+ }
+
+ @Override
+ public Key engineGetKey(String alias, char[] password) {
+ PrivateKeyEntry entry = entries.get(alias);
+ return entry == null ? null : entry.getPrivateKey();
+ }
+
+ @Override
+ public Certificate[] engineGetCertificateChain(String alias) {
+ PrivateKeyEntry entry = entries.get(alias);
+ return entry == null ? null : entry.getCertificateChain();
+ }
+
+ @Override
+ public Certificate engineGetCertificate(String alias) {
+ PrivateKeyEntry entry = entries.get(alias);
+ return entry == null ? null : entry.getCertificate();
+ }
+
+ @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().getCertificate().equals(cert)) return entry.getKey();
+ }
+ return null;
+ }
+
+ @Override
+ 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) throws KeyStoreException {
+ throw new KeyStoreException("read-only TLS credentials");
+ }
+
+ @Override
+ public void engineSetCertificateEntry(String alias, Certificate cert) throws KeyStoreException {
+ throw new KeyStoreException("read-only TLS credentials");
+ }
+
+ @Override
+ public void engineDeleteEntry(String alias) throws KeyStoreException {
+ throw new KeyStoreException("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) {
+ if (stream != null) throw new UnsupportedOperationException("read-only TLS credentials");
+ }
+
+ @Override
+ 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
new file mode 100644
index 00000000..3fc72d94
--- /dev/null
+++ b/src/test/java/org/jruby/ext/openssl/SSLKeyStoreTest.java
@@ -0,0 +1,146 @@
+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 KeyStore.PrivateKeyEntry(
+ 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 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(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"));
+ }
+
+ @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