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);
+    }
+}

Reply via email to