You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@plc4x.apache.org by cd...@apache.org on 2026/08/14 16:16:23 UTC
(plc4x) branch develop updated: fix(plc4j/opcua): Pick a usable client identity out of the key store
This is an automated email from the ASF dual-hosted git repository.
chrisdutz pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/plc4x.git
The following commit(s) were added to refs/heads/develop by this push:
new a2b5dffbc7 fix(plc4j/opcua): Pick a usable client identity out of the key store
a2b5dffbc7 is described below
commit a2b5dffbc714288585ce0a301fab7710f7733144
Author: Christofer Dutz <ch...@c-ware.de>
AuthorDate: Fri Aug 14 18:16:13 2026 +0200
fix(plc4j/opcua): Pick a usable client identity out of the key store
The driver took whatever alias came first in the key store, without checking
that it was a key entry at all:
String alias = keyStore.aliases().nextElement();
Key stores routinely hold more than one entry. The certificate tutorial walks
users through creating a self-signed CA alongside the client certificate, and
"ca" sorts before "client" - so the driver could hand the CA's entry to the
server, or pick an entry whose getKey() returns null and carry on with a null
private key. Neither produced a diagnosable error; the server just closed the
connection.
The first entry holding a private key is now selected, and the entry is checked
before use. A key store with no private key at all, an entry whose key could not
be read (which is what a wrong key-store-password looks like) and a non-RSA key
each fail with a message naming the entry and the reason. The last one matters
because the tutorial offers RSA, DSA and EC while OPC UA application instance
certificates have to be RSA - picking one of the other two produced a handshake
failure with no hint as to why.
The chosen entry is also logged with its key size and subject, so a report about
a certificate problem says which identity was actually used.
Relates to: #2196, #2286
---
.../java/opcua/context/OpcuaDriverContext.java | 55 +++++++-
.../java/opcua/context/KeyStoreSelectionTest.java | 150 +++++++++++++++++++++
2 files changed, 202 insertions(+), 3 deletions(-)
diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
index e8071d2865..2b9de48665 100644
--- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
+++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java
@@ -23,6 +23,7 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
+import java.util.Enumeration;
import java.util.Optional;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
@@ -42,9 +43,11 @@ import java.io.File;
import java.io.FileInputStream;
import java.security.KeyPair;
import java.security.KeyStore;
+import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Security;
import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -104,9 +107,15 @@ public class OpcuaDriverContext {
LOGGER.info("Loading KeyStore at {}", serverKeyStore);
KeyStore keyStore = openKeyStore(configuration.getKeyStoreFile(), configuration.getKeyStoreType(), configuration.getKeyStorePassword());
- String alias = keyStore.aliases().nextElement();
- KeyPair kp = new KeyPair(keyStore.getCertificate(alias).getPublicKey(), (PrivateKey) keyStore.getKey(alias, configuration.getKeyStorePassword()));
- certificateKeyPair = new CertificateKeyPair(kp, (X509Certificate) keyStore.getCertificate(alias));
+ String alias = selectKeyAlias(keyStore, configuration.getKeyStoreFile());
+ X509Certificate certificate = (X509Certificate) keyStore.getCertificate(alias);
+ PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, configuration.getKeyStorePassword());
+ validateClientKey(alias, certificate, privateKey);
+ LOGGER.info("Using entry '{}' of the key store: {} bit {} certificate for '{}'", alias,
+ ((RSAPublicKey) certificate.getPublicKey()).getModulus().bitLength(),
+ certificate.getPublicKey().getAlgorithm(), certificate.getSubjectX500Principal());
+ KeyPair kp = new KeyPair(certificate.getPublicKey(), privateKey);
+ certificateKeyPair = new CertificateKeyPair(kp, certificate);
}
if (configuration.getServerCertificate() != null) {
@@ -118,6 +127,46 @@ public class OpcuaDriverContext {
certificateVerifier = buildCertificateVerifier(configuration);
}
+ /**
+ * Picks the key store entry to authenticate with: the first one that actually holds a private
+ * key. Taking whatever alias came first meant a key store that also carries the signing CA (as
+ * produced by the certificate tutorial) could hand the CA's entry to the server, or an entry
+ * with no private key at all.
+ */
+ private String selectKeyAlias(KeyStore keyStore, String keyStoreFile) throws GeneralSecurityException {
+ Enumeration<String> aliases = keyStore.aliases();
+ while (aliases.hasMoreElements()) {
+ String alias = aliases.nextElement();
+ if (keyStore.isKeyEntry(alias)) {
+ return alias;
+ }
+ LOGGER.debug("Skipping key store entry '{}': it holds no private key", alias);
+ }
+ throw new KeyStoreException("The key store '" + keyStoreFile
+ + "' contains no entry with a private key, so it cannot be used as a client identity.");
+ }
+
+ /**
+ * OPC UA application instance certificates are RSA; anything else is rejected by the server
+ * during the handshake, typically with an error that says nothing about the cause. Failing here
+ * names the offending key store entry instead.
+ */
+ private void validateClientKey(String alias, X509Certificate certificate, PrivateKey privateKey)
+ throws GeneralSecurityException {
+ if (certificate == null) {
+ throw new KeyStoreException("Key store entry '" + alias + "' has no certificate.");
+ }
+ if (privateKey == null) {
+ throw new KeyStoreException("Key store entry '" + alias + "' has no private key."
+ + " Check the key store password - a wrong one yields an entry without a key.");
+ }
+ if (!(certificate.getPublicKey() instanceof RSAPublicKey)) {
+ throw new KeyStoreException("Key store entry '" + alias + "' uses a "
+ + certificate.getPublicKey().getAlgorithm() + " key. OPC UA requires RSA keys for"
+ + " application instance certificates; regenerate the certificate with an RSA key.");
+ }
+ }
+
/**
* Selects the server-certificate trust strategy, in order of precedence:
* <ol>
diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/KeyStoreSelectionTest.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/KeyStoreSelectionTest.java
new file mode 100644
index 0000000000..c1c6a4e836
--- /dev/null
+++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/KeyStoreSelectionTest.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.plc4x.java.opcua.context;
+
+import org.apache.plc4x.java.opcua.TestCertificateGenerator;
+import org.apache.plc4x.java.opcua.config.OpcuaConfiguration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.FileOutputStream;
+import java.io.OutputStream;
+import java.math.BigInteger;
+import java.nio.file.Path;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.cert.X509Certificate;
+import java.util.Date;
+import java.util.Map.Entry;
+
+import org.bouncycastle.cert.X509CertificateHolder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+
+import javax.security.auth.x500.X500Principal;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Choosing the client identity out of a key store. The driver used to take whatever alias came
+ * first, which breaks for the key stores the certificate tutorial produces - those also contain the
+ * signing CA - and gave no useful error when the entry could not be used at all (see the
+ * discussion in GH-2013 / GH-2196).
+ */
+class KeyStoreSelectionTest {
+
+ private static final char[] PASSWORD = "changeit".toCharArray();
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void picksTheEntryThatHasAPrivateKey() throws Exception {
+ KeyStore keyStore = emptyKeyStore();
+ // A trusted certificate entry sorting before the key entry - "ca" < "client".
+ keyStore.setCertificateEntry("ca", TestCertificateGenerator.generate(2048, "CN=ca", 3600).getValue());
+ Entry<PrivateKey, X509Certificate> client = TestCertificateGenerator.generate(2048, "CN=client", 3600);
+ keyStore.setKeyEntry("client", client.getKey(), PASSWORD, new java.security.cert.Certificate[]{client.getValue()});
+
+ CertificateKeyPair selected = load(keyStore);
+
+ assertEquals(client.getValue(), selected.getCertificate());
+ assertNotNull(selected.getKeyPair().getPrivate());
+ }
+
+ @Test
+ void reportsAKeyStoreWithoutAnyPrivateKey() throws Exception {
+ KeyStore keyStore = emptyKeyStore();
+ keyStore.setCertificateEntry("ca", TestCertificateGenerator.generate(2048, "CN=ca", 3600).getValue());
+
+ GeneralSecurityException exception = assertThrows(GeneralSecurityException.class, () -> load(keyStore));
+
+ assertTrue(exception.getMessage().contains("no entry with a private key"), exception.getMessage());
+ }
+
+ /**
+ * OPC UA application instance certificates have to be RSA. The certificate tutorial offers
+ * RSA, DSA and EC, and picking one of the latter two produced an opaque handshake failure.
+ */
+ @Test
+ void reportsANonRsaKey() throws Exception {
+ KeyStore keyStore = emptyKeyStore();
+ Entry<PrivateKey, X509Certificate> ecEntry = generateEcEntry();
+ keyStore.setKeyEntry("client", ecEntry.getKey(), PASSWORD,
+ new java.security.cert.Certificate[]{ecEntry.getValue()});
+
+ GeneralSecurityException exception = assertThrows(GeneralSecurityException.class, () -> load(keyStore));
+
+ assertTrue(exception.getMessage().contains("EC"), exception.getMessage());
+ assertTrue(exception.getMessage().contains("RSA"), exception.getMessage());
+ }
+
+ private CertificateKeyPair load(KeyStore keyStore) throws Exception {
+ Path file = tempDir.resolve("keystore-" + System.nanoTime() + ".p12");
+ try (OutputStream outputStream = new FileOutputStream(file.toFile())) {
+ keyStore.store(outputStream, PASSWORD);
+ }
+
+ // The configuration has no setters - the framework injects the fields.
+ OpcuaConfiguration configuration = new OpcuaConfiguration();
+ set(configuration, "keyStoreFile", file.toAbsolutePath().toString());
+ set(configuration, "keyStoreType", "pkcs12");
+ set(configuration, "keyStorePassword", new String(PASSWORD));
+
+ OpcuaDriverContext context = new OpcuaDriverContext();
+ context.openKeyStore(configuration);
+ return context.getCertificateKeyPair();
+ }
+
+ private static void set(OpcuaConfiguration configuration, String fieldName, Object value) throws Exception {
+ java.lang.reflect.Field field = OpcuaConfiguration.class.getDeclaredField(fieldName);
+ field.setAccessible(true);
+ field.set(configuration, value);
+ }
+
+ private KeyStore emptyKeyStore() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(null, PASSWORD);
+ return keyStore;
+ }
+
+ private Entry<PrivateKey, X509Certificate> generateEcEntry() throws Exception {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(256, new SecureRandom());
+ KeyPair keyPair = generator.generateKeyPair();
+
+ X500Principal dn = new X500Principal("CN=ec-client");
+ X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
+ dn, BigInteger.valueOf(1), new Date(System.currentTimeMillis() - 1000),
+ new Date(System.currentTimeMillis() + 3_600_000), dn, keyPair.getPublic());
+ X509CertificateHolder holder = builder.build(
+ new JcaContentSignerBuilder("SHA256withECDSA").build(keyPair.getPrivate()));
+ X509Certificate certificate = new JcaX509CertificateConverter().getCertificate(holder);
+ return java.util.Map.entry(keyPair.getPrivate(), certificate);
+ }
+}