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 acab0495ab fix(plc4j/opcua): Send the certificate chain, not just the
client certificate
acab0495ab is described below
commit acab0495abab389d59bce91bbc52e82a93204386
Author: Christofer Dutz <[email protected]>
AuthorDate: Sat Aug 15 11:44:35 2026 +0200
fix(plc4j/opcua): Send the certificate chain, not just the client
certificate
The asymmetric security header carried only the client certificate:
byte[] encoded = keyPair.getCertificate().getEncoded();
A certificate issued by a CA cannot be verified on its own by a server that
trusts the issuing CA rather than the certificate itself. OPC UA Part 6
allows
the issuing certificates to be appended to SenderCertificate for exactly
that
reason, and servers that cannot otherwise build a path to their trust anchor
need them. Every certificate in the test suite is self-signed, which is why
this
went unnoticed - a self-signed certificate is its own chain.
CertificateKeyPair now holds the chain as it comes out of the key store and
exposes the concatenated DER that goes on the wire, OpcuaDriverContext
reads it
via getCertificateChain(), and SecureChannel sends it. The thumbprint keeps
covering the client certificate alone, as the spec requires.
The chunk calculation had to follow: the asymmetric security header is sized
from the local certificate, so sending a chain while sizing for a single
certificate would have mis-sized every chunk. ChunkFactory takes the number
of
bytes actually sent, which SecureChannelState reports with a default that
falls
back to the single certificate.
The tests TestCertificateGenerator can now issue a certificate signed by
another,
so a real chain can be built; KeyStoreSelectionTest covers loading it, the
encoding on the wire and the thumbprint staying on the leaf.
RealKeyStoreManualTest checks a key store issued by a public CA, which
carries
extensions and chain depths our generator does not produce. Such a key store
holds private key material and cannot be committed, so the test reads a path
from a system property and skips without it:
export PLC4X_OPCUA_TEST_KEYSTORE_PASSWORD='<the password>'
mvn test -Dtest=RealKeyStoreManualTest \
-Dplc4x.opcua.test.keystore=/path/to/identity.p12
Tested with my yubikey-based ssl.com certificate.
Relates to: #2013
---
.../java/opcua/context/CertificateKeyPair.java | 45 +++++++-
.../plc4x/java/opcua/context/Conversation.java | 12 ++
.../java/opcua/context/EncryptionHandler.java | 3 +-
.../java/opcua/context/OpcuaDriverContext.java | 33 +++++-
.../plc4x/java/opcua/context/SecureChannel.java | 11 +-
.../java/opcua/context/SecureChannelState.java | 17 +++
.../java/opcua/protocol/chunk/ChunkFactory.java | 15 ++-
.../plc4x/java/opcua/TestCertificateGenerator.java | 29 +++++
.../java/opcua/context/EncryptionHandlerTest.java | 10 ++
.../java/opcua/context/KeyStoreSelectionTest.java | 47 ++++++++
.../java/opcua/context/RealKeyStoreManualTest.java | 123 +++++++++++++++++++++
11 files changed, 335 insertions(+), 10 deletions(-)
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/CertificateKeyPair.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/CertificateKeyPair.java
index d61587550c..92069954c2 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/CertificateKeyPair.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/CertificateKeyPair.java
@@ -22,6 +22,7 @@ import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import org.bouncycastle.asn1.x509.GeneralName;
+import java.io.ByteArrayOutputStream;
import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
@@ -33,15 +34,57 @@ public class CertificateKeyPair {
private final KeyPair keyPair;
private final X509Certificate certificate;
+ private final List<X509Certificate> certificateChain;
+ private final byte[] encodedCertificateChain;
private final byte[] thumbprint;
public CertificateKeyPair(KeyPair keyPair, X509Certificate certificate)
throws GeneralSecurityException {
+ this(keyPair, List.of(certificate));
+ }
+
+ /**
+ * @param certificateChain the client certificate followed by the
certificates that signed it,
+ * as it comes out of a key store. Everything but
the first element is
+ * only relevant for a CA-signed certificate.
+ */
+ public CertificateKeyPair(KeyPair keyPair, List<X509Certificate>
certificateChain) throws GeneralSecurityException {
+ if (certificateChain == null || certificateChain.isEmpty()) {
+ throw new IllegalArgumentException("A certificate chain needs at
least the certificate itself");
+ }
this.keyPair = keyPair;
- this.certificate = certificate;
+ this.certificate = certificateChain.get(0);
+ this.certificateChain = List.copyOf(certificateChain);
+ this.encodedCertificateChain = encode(this.certificateChain);
MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
+ // The thumbprint identifies the sender's own certificate, never the
chain.
this.thumbprint = messageDigest.digest(this.certificate.getEncoded());
}
+ /**
+ * The certificate chain, the client's own certificate first.
+ */
+ public List<X509Certificate> getCertificateChain() {
+ return certificateChain;
+ }
+
+ /**
+ * The bytes that go into the {@code SenderCertificate} field of the
asymmetric security
+ * header: the DER encoded client certificate, followed by the DER
encoding of each CA
+ * certificate that signed it. OPC UA Part 6 allows appending the issuing
certificates, and
+ * servers that cannot otherwise build a path to their trust anchor need
them.
+ */
+ public byte[] getEncodedCertificateChain() {
+ return encodedCertificateChain;
+ }
+
+ private static byte[] encode(List<X509Certificate> chain) throws
GeneralSecurityException {
+ ByteArrayOutputStream encoded = new ByteArrayOutputStream();
+ for (X509Certificate certificate : chain) {
+ encoded.writeBytes(certificate.getEncoded());
+ }
+ return encoded.toByteArray();
+ }
+
public KeyPair getKeyPair() {
return keyPair;
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/Conversation.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/Conversation.java
index cc8731bba6..eafdc2cd17 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/Conversation.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/Conversation.java
@@ -136,6 +136,7 @@ public class Conversation implements SecureChannelState {
private OpcuaProtocolLimits limits;
private X509Certificate localCertificate = null;
+ private int localCertificateChainSize = 0;
private X509Certificate remoteCertificate = null;
private byte[] remoteNonce;
private byte[] localNonce;
@@ -176,6 +177,9 @@ public class Conversation implements SecureChannelState {
this.remoteCertificate = configuration.getServerCertificate();
this.encryptionHandler = new EncryptionHandler(this,
senderKeyPair.getPrivateKey());
this.localCertificate = senderKeyPair.getCertificate();
+ // The header is sized against what actually goes on the wire,
which for a CA-signed
+ // certificate is the whole chain rather than the certificate
alone.
+ this.localCertificateChainSize =
senderKeyPair.getEncodedCertificateChain().length;
} else {
this.messageSecurity = MessageSecurity.NONE;
this.encryptionHandler = new EncryptionHandler(this, null);
@@ -582,6 +586,14 @@ public class Conversation implements SecureChannelState {
return localCertificate;
}
+ /**
+ * Number of bytes the local certificate occupies in the asymmetric
security header, i.e. the
+ * whole certificate chain when there is one.
+ */
+ public int getLocalCertificateChainSize() {
+ return localCertificateChainSize;
+ }
+
public void setRemoteNonce(byte[] remoteNonce) {
this.remoteNonce = remoteNonce;
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/EncryptionHandler.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/EncryptionHandler.java
index f57d370330..d6eb88bb4a 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/EncryptionHandler.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/EncryptionHandler.java
@@ -67,7 +67,8 @@ public class EncryptionHandler {
if (message instanceof OpcuaOpenRequest || message instanceof
OpcuaOpenResponse) {
Chunk chunk = new ChunkFactory().create(true,
conversation.isSymmetricEncryptionEnabled(),
conversation.isSymmetricSigningEnabled(),
conversation.getSecurityPolicy(), limits,
- conversation.getLocalCertificate(),
conversation.getRemoteCertificate()
+ conversation.getLocalCertificate(),
conversation.getRemoteCertificate(),
+ conversation.getLocalCertificateChainSize()
);
return asymmetricEncryptionHandler.encodeMessage(chunk, message,
sequenceSupplier);
}
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 2b9de48665..82ad6624ff 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,7 +23,9 @@ import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Paths;
import java.security.GeneralSecurityException;
+import java.util.ArrayList;
import java.util.Enumeration;
+import java.util.List;
import java.util.Optional;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
@@ -46,6 +48,7 @@ import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.Security;
+import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPublicKey;
import java.util.regex.Matcher;
@@ -115,7 +118,7 @@ public class OpcuaDriverContext {
((RSAPublicKey)
certificate.getPublicKey()).getModulus().bitLength(),
certificate.getPublicKey().getAlgorithm(),
certificate.getSubjectX500Principal());
KeyPair kp = new KeyPair(certificate.getPublicKey(), privateKey);
- certificateKeyPair = new CertificateKeyPair(kp, certificate);
+ certificateKeyPair = new CertificateKeyPair(kp,
certificateChain(keyStore, alias, certificate));
}
if (configuration.getServerCertificate() != null) {
@@ -146,6 +149,34 @@ public class OpcuaDriverContext {
+ "' contains no entry with a private key, so it cannot be used as
a client identity.");
}
+ /**
+ * The certificate chain stored under the given alias, the client
certificate first. A
+ * CA-signed client certificate is only verifiable by a server that can
build a path to its
+ * trust anchor, which usually means the issuing certificates have to
travel with it - see
+ * OPC UA Part 6, SenderCertificate. A self-signed certificate simply
yields a chain of one.
+ */
+ private List<X509Certificate> certificateChain(KeyStore keyStore, String
alias, X509Certificate certificate)
+ throws GeneralSecurityException {
+ Certificate[] chain = keyStore.getCertificateChain(alias);
+ if (chain == null || chain.length == 0) {
+ return List.of(certificate);
+ }
+ List<X509Certificate> certificates = new ArrayList<>(chain.length);
+ for (Certificate element : chain) {
+ if (element instanceof X509Certificate x509) {
+ certificates.add(x509);
+ } else {
+ LOGGER.warn("Ignoring a {} certificate in the chain of key
store entry '{}'",
+ element.getType(), alias);
+ }
+ }
+ if (certificates.size() > 1) {
+ LOGGER.info("Key store entry '{}' carries a chain of {}
certificates; sending all of them",
+ alias, certificates.size());
+ }
+ return certificates.isEmpty() ? List.of(certificate) : certificates;
+ }
+
/**
* 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
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
index 2f9cba5609..944a645256 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java
@@ -117,12 +117,11 @@ public class SecureChannel {
} else {
CertificateKeyPair keyPair = driverContext.getCertificateKeyPair();
this.remoteCertificateThumbprint = driverContext.getThumbprint();
- try {
- byte[] encoded = keyPair.getCertificate().getEncoded();
- this.localCertificateString = new
PascalByteString(encoded.length, encoded);
- } catch (CertificateEncodingException e) {
- throw new PlcRuntimeException("Could not decode certificate",
e);
- }
+ // Send the whole chain: a CA-signed certificate is not verifiable
on its own by a
+ // server that only trusts the issuing CA (OPC UA Part 6,
SenderCertificate). For a
+ // self-signed certificate this is just that one certificate.
+ byte[] encoded = keyPair.getEncodedCertificateChain();
+ this.localCertificateString = new PascalByteString(encoded.length,
encoded);
}
}
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannelState.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannelState.java
index aa0acb6428..f783b1e9da 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannelState.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannelState.java
@@ -42,6 +42,23 @@ public interface SecureChannelState {
X509Certificate getLocalCertificate();
+ /**
+ * Number of bytes the local certificate occupies in the asymmetric
security header. That is
+ * the certificate itself, unless a CA-signed certificate is sent together
with the
+ * certificates that signed it, in which case the header is
correspondingly larger.
+ */
+ default int getLocalCertificateChainSize() {
+ X509Certificate certificate = getLocalCertificate();
+ if (certificate == null) {
+ return 0;
+ }
+ try {
+ return certificate.getEncoded().length;
+ } catch (java.security.cert.CertificateEncodingException e) {
+ return 0;
+ }
+ }
+
X509Certificate getRemoteCertificate();
boolean isSymmetricEncryptionEnabled();
diff --git
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/chunk/ChunkFactory.java
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/chunk/ChunkFactory.java
index 4ffc2a2ef2..563d920140 100644
---
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/chunk/ChunkFactory.java
+++
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/protocol/chunk/ChunkFactory.java
@@ -34,6 +34,19 @@ public class ChunkFactory {
public Chunk create(boolean asymmetric, boolean encrypted, boolean signed,
SecurityPolicy securityPolicy,
OpcuaProtocolLimits limits, X509Certificate localCertificate,
X509Certificate remoteCertificate) {
+ return create(asymmetric, encrypted, signed, securityPolicy, limits,
localCertificate, remoteCertificate,
+ certificateBytes(localCertificate).length);
+ }
+
+ /**
+ * @param sentCertificateSize number of bytes the {@code
SenderCertificate} field occupies. That
+ * is the local certificate on its own, unless
a CA-signed
+ * certificate travels together with the
certificates that signed it,
+ * in which case the header grows accordingly.
+ */
+ public Chunk create(boolean asymmetric, boolean encrypted, boolean signed,
SecurityPolicy securityPolicy,
+ OpcuaProtocolLimits limits, X509Certificate localCertificate,
X509Certificate remoteCertificate,
+ int sentCertificateSize) {
if (securityPolicy == SecurityPolicy.NONE) {
return new Chunk(
@@ -64,7 +77,7 @@ public class ChunkFactory {
+ "RSA certificate is available to encrypt the
OpenSecureChannel request. Provide the server "
+ "certificate via 'server-certificate-file', or use
security-policy=NONE.");
}
- int localCertificateSize = asymmetric ?
certificateBytes(localCertificate).length : 0;
+ int localCertificateSize = asymmetric ? sentCertificateSize : 0;
int serverCertificateThumbprint = asymmetric ?
certificateThumbprint(remoteCertificate).length : 0;
int asymmetricSecurityHeaderSize = (12 +
securityPolicy.getSecurityPolicyUri().length() + localCertificateSize +
serverCertificateThumbprint);
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/TestCertificateGenerator.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/TestCertificateGenerator.java
index e9e5392e89..f8ab0ba5ab 100644
---
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/TestCertificateGenerator.java
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/TestCertificateGenerator.java
@@ -55,6 +55,35 @@ public class TestCertificateGenerator {
return new BigInteger(62, new SecureRandom()).setBit(62);
}
+ /**
+ * Generates a certificate signed by the given issuer, so tests can build
a real certificate
+ * chain. A self-signed certificate never exercises the code paths that
deal with chains.
+ */
+ public static Entry<PrivateKey, X509Certificate> generateSignedBy(int
keySize, String dn, long validitySec,
+ PrivateKey issuerKey, X509Certificate issuerCertificate) {
+ try {
+ KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
+ keyPairGenerator.initialize(keySize, new SecureRandom());
+ KeyPair keyPair = keyPairGenerator.generateKeyPair();
+
+ X509v3CertificateBuilder certGen = new JcaX509v3CertificateBuilder(
+ issuerCertificate.getSubjectX500Principal(),
+ serialNumber(),
+ new Date(),
+ new Date(System.currentTimeMillis() +
TimeUnit.SECONDS.toMillis(validitySec)),
+ new X500Principal(dn),
+ keyPair.getPublic()
+ );
+ X509CertificateHolder cert = certGen.build(new
JcaContentSignerBuilder("SHA256withRSA")
+ .build(issuerKey));
+
+ X509Certificate certificate = new
JcaX509CertificateConverter().getCertificate(cert);
+ return Map.entry(keyPair.getPrivate(), certificate);
+ } catch (CertificateException | NoSuchAlgorithmException |
OperatorCreationException e) {
+ throw new RuntimeException("Could not initialize test -
certificate generation failed", e);
+ }
+ }
+
public static Entry<PrivateKey, X509Certificate> generate(int keySize,
String dn, long validitySec) {
try {
KeyPairGenerator keyPairGenerator =
KeyPairGenerator.getInstance("RSA");
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/EncryptionHandlerTest.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/EncryptionHandlerTest.java
index 897d9fb60b..4fe856493c 100644
---
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/EncryptionHandlerTest.java
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/EncryptionHandlerTest.java
@@ -301,12 +301,22 @@ class EncryptionHandlerTest {
return new PascalByteString(bytes.length, bytes);
}
+ private static int encodedLength(X509Certificate certificate) {
+ try {
+ return certificate == null ? 0 : certificate.getEncoded().length;
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
private static Conversation createSecureChannel(X509Certificate
localCertificate, X509Certificate remoteCertificate, SecurityPolicy
securityPolicy,
MessageSecurity messageSecurity, boolean encrypted, boolean signed) {
OpcuaProtocolLimits limits = new OpcuaProtocolLimits(8196L, 8196L,
8196L * 10, 10L);
Conversation conversation = Mockito.mock(Conversation.class);
when(conversation.getLimits()).thenReturn(limits);
when(conversation.getLocalCertificate()).thenReturn(localCertificate);
+ // Mirrors production: without a CA chain the header carries just this
certificate.
+
when(conversation.getLocalCertificateChainSize()).thenReturn(encodedLength(localCertificate));
when(conversation.getRemoteCertificate()).thenReturn(remoteCertificate);
when(conversation.getSecurityPolicy()).thenReturn(securityPolicy);
when(conversation.getMessageSecurity()).thenReturn(messageSecurity);
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
index c1c6a4e836..77415878e9 100644
---
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
@@ -45,6 +45,7 @@ import
org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import javax.security.auth.x500.X500Principal;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -104,6 +105,52 @@ class KeyStoreSelectionTest {
assertTrue(exception.getMessage().contains("RSA"),
exception.getMessage());
}
+ /**
+ * A CA-signed client certificate cannot be verified on its own by a
server that only trusts the
+ * issuing CA, so the certificates that signed it have to travel with it -
see OPC UA Part 6,
+ * SenderCertificate. Every certificate in the test suite is self-signed,
which is why this case
+ * went unnoticed (GH-2013).
+ */
+ @Test
+ void sendsTheWholeChainForACaSignedCertificate() throws Exception {
+ Entry<PrivateKey, X509Certificate> ca =
TestCertificateGenerator.generate(2048, "CN=test-ca", 3600);
+ Entry<PrivateKey, X509Certificate> client =
+ TestCertificateGenerator.generateSignedBy(2048, "CN=client", 3600,
ca.getKey(), ca.getValue());
+
+ KeyStore keyStore = emptyKeyStore();
+ keyStore.setKeyEntry("client", client.getKey(), PASSWORD,
+ new java.security.cert.Certificate[]{client.getValue(),
ca.getValue()});
+
+ CertificateKeyPair selected = load(keyStore);
+
+ assertEquals(2, selected.getCertificateChain().size());
+ assertEquals(client.getValue(), selected.getCertificate(), "the client
certificate comes first");
+
+ byte[] encoded = selected.getEncodedCertificateChain();
+ assertEquals(client.getValue().getEncoded().length +
ca.getValue().getEncoded().length, encoded.length,
+ "the chain on the wire is the concatenation of both certificates");
+ assertArrayEquals(client.getValue().getEncoded(),
+ java.util.Arrays.copyOf(encoded,
client.getValue().getEncoded().length));
+ }
+
+ /**
+ * The thumbprint identifies the sender's own certificate, never the chain.
+ */
+ @Test
+ void thumbprintCoversOnlyTheClientCertificate() throws Exception {
+ Entry<PrivateKey, X509Certificate> ca =
TestCertificateGenerator.generate(2048, "CN=test-ca", 3600);
+ Entry<PrivateKey, X509Certificate> client =
+ TestCertificateGenerator.generateSignedBy(2048, "CN=client", 3600,
ca.getKey(), ca.getValue());
+
+ CertificateKeyPair withChain = new CertificateKeyPair(
+ new KeyPair(client.getValue().getPublicKey(), client.getKey()),
+ java.util.List.of(client.getValue(), ca.getValue()));
+ CertificateKeyPair leafOnly = new CertificateKeyPair(
+ new KeyPair(client.getValue().getPublicKey(), client.getKey()),
client.getValue());
+
+ assertArrayEquals(leafOnly.getThumbPrint(), withChain.getThumbPrint());
+ }
+
private CertificateKeyPair load(KeyStore keyStore) throws Exception {
Path file = tempDir.resolve("keystore-" + System.nanoTime() + ".p12");
try (OutputStream outputStream = new FileOutputStream(file.toFile())) {
diff --git
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/RealKeyStoreManualTest.java
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/RealKeyStoreManualTest.java
new file mode 100644
index 0000000000..6d528583f5
--- /dev/null
+++
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/RealKeyStoreManualTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.config.OpcuaConfiguration;
+import org.junit.jupiter.api.Assumptions;
+import org.junit.jupiter.api.Test;
+
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Checks a real, CA-issued key store against the loading logic. Certificates
from a public CA
+ * differ from the self-signed ones the rest of the suite generates: they
carry a chain, they can
+ * hold extra entries, and their extensions come from whatever the CA issues
rather than from our
+ * own generator - which is the shape reported as failing in GH-2013 and
GH-2196.
+ * <p>
+ * Such a key store contains private key material, so it cannot live in the
repository. Point this
+ * test at one instead; without the properties it skips.
+ *
+ * <pre>
+ * export PLC4X_OPCUA_TEST_KEYSTORE_PASSWORD='secret'
+ * mvn test -Dtest=RealKeyStoreManualTest \
+ * -Dplc4x.opcua.test.keystore=/absolute/path/to/identity.p12 \
+ * [-Dplc4x.opcua.test.keystore-type=pkcs12]
+ * </pre>
+ * <p>
+ * The password may also be given as {@code
-Dplc4x.opcua.test.keystore-password}, but the
+ * environment variable is preferable: a password on the command line lands in
the shell history
+ * and in the process list, and unquoted special characters in it are mangled
by the shell before
+ * Maven ever sees them.
+ */
+class RealKeyStoreManualTest {
+
+ private static final String KEY_STORE_PROPERTY =
"plc4x.opcua.test.keystore";
+ private static final String PASSWORD_PROPERTY =
"plc4x.opcua.test.keystore-password";
+ private static final String PASSWORD_ENV =
"PLC4X_OPCUA_TEST_KEYSTORE_PASSWORD";
+ private static final String TYPE_PROPERTY =
"plc4x.opcua.test.keystore-type";
+
+ @Test
+ void loadsTheClientIdentityFromARealKeyStore() throws Exception {
+ String keyStoreFile = System.getProperty(KEY_STORE_PROPERTY);
+ Assumptions.assumeTrue(keyStoreFile != null && !keyStoreFile.isEmpty(),
+ "set -D" + KEY_STORE_PROPERTY + " to run this against a real key
store");
+
+ OpcuaConfiguration configuration = new OpcuaConfiguration();
+ set(configuration, "keyStoreFile", keyStoreFile);
+ set(configuration, "keyStoreType", System.getProperty(TYPE_PROPERTY,
"pkcs12"));
+ set(configuration, "keyStorePassword", password());
+
+ OpcuaDriverContext context = new OpcuaDriverContext();
+ context.openKeyStore(configuration);
+ CertificateKeyPair identity = context.getCertificateKeyPair();
+
+ assertNotNull(identity.getPrivateKey(), "no private key was selected");
+ assertTrue(identity.getCertificate().getPublicKey() instanceof
RSAPublicKey,
+ "OPC UA requires an RSA key, this key store holds a "
+ + identity.getCertificate().getPublicKey().getAlgorithm() + "
key");
+
+ List<X509Certificate> chain = identity.getCertificateChain();
+ assertEquals(identity.getCertificate(), chain.get(0), "the client
certificate has to come first");
+
+ // The bytes that would go into the SenderCertificate field.
+ int expected = 0;
+ for (X509Certificate certificate : chain) {
+ expected += certificate.getEncoded().length;
+ }
+ assertEquals(expected, identity.getEncodedCertificateChain().length);
+
+ // Deliberately printed, not asserted: the interesting part is what a
real CA issues.
+ System.out.println("key store entry: " +
identity.getCertificate().getSubjectX500Principal());
+ System.out.println("issuer : " +
identity.getCertificate().getIssuerX500Principal());
+ System.out.println("key : "
+ + ((RSAPublicKey)
identity.getCertificate().getPublicKey()).getModulus().bitLength() + " bit "
+ + identity.getCertificate().getPublicKey().getAlgorithm());
+ System.out.println("signature : " +
identity.getCertificate().getSigAlgName());
+ System.out.println("chain length : " + chain.size()
+ + " (" + identity.getEncodedCertificateChain().length + " bytes on
the wire)");
+ for (X509Certificate certificate : chain) {
+ System.out.println(" - " + certificate.getSubjectX500Principal()
+ + " signed by " + certificate.getIssuerX500Principal());
+ }
+ }
+
+ /**
+ * The environment variable wins: it survives special characters that a
shell would otherwise
+ * expand, and keeps the password out of the shell history and the process
list.
+ */
+ private static String password() {
+ String fromEnvironment = System.getenv(PASSWORD_ENV);
+ if (fromEnvironment != null && !fromEnvironment.isEmpty()) {
+ return fromEnvironment;
+ }
+ return System.getProperty(PASSWORD_PROPERTY, "");
+ }
+
+ 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);
+ }
+}