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 a2dbb6bfc0 fix: Addessed various issues with the OPC-UA driver and 
it's testsuite.
a2dbb6bfc0 is described below

commit a2dbb6bfc0ef0060674d5614d99f32da0c749795
Author: Christofer Dutz <[email protected]>
AuthorDate: Fri Jul 10 19:18:45 2026 +0200

    fix: Addessed various issues with the OPC-UA driver and it's testsuite.
---
 .../apache/plc4x/java/opcua/OpcuaConnection.java   |  21 +-
 .../java/opcua/config/OpcuaConfiguration.java      |  22 +
 .../opcua/context/AsymmetricEncryptionHandler.java |   2 +-
 .../java/opcua/context/BaseEncryptionHandler.java  |   7 +-
 .../java/opcua/context/OpcuaDriverContext.java     |  41 +-
 .../plc4x/java/opcua/context/SecureChannel.java    |  29 +-
 .../opcua/security/PinnedCertificateVerifier.java  |  50 ++
 .../security/RejectingCertificateVerifier.java     |  43 ++
 .../apache/plc4x/java/opcua/KeystoreGenerator.java | 115 ++++
 .../apache/plc4x/java/opcua/MiloTestContainer.java |  64 ++
 .../plc4x/java/opcua/OpcuaPlcDriverTest.java       | 697 +++++++++++++++++++++
 .../security/PinnedCertificateVerifierTest.java    |  54 ++
 .../security/RejectingCertificateVerifierTest.java |  42 ++
 .../milo/examples/server/TestMiloServer.java       |   7 +
 .../plc4x/java/spi/drivers/ConnectionBase.java     |  17 +
 .../apache/plc4x/java/spi/drivers/DriverBase.java  |   3 +
 .../drivers/messages/DefaultPlcWriteRequest.java   |   9 +-
 .../java/spi/values/DefaultPlcValueHandler.java    |  19 +-
 .../spi/transports/api/BaseTransportInstance.java  |  18 +
 .../java/spi/transports/api/TransportInstance.java |  16 +
 .../plc4x/java/transport/tcp/TcpTransport.java     |   9 +-
 .../plc4x/java/transport/tls/TlsTransport.java     |   8 +-
 22 files changed, 1268 insertions(+), 25 deletions(-)

diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
index b24c3dbacf..fb31b356bd 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java
@@ -142,7 +142,11 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
             getTransportCode(),
             remote.getHostString(),
             String.valueOf(remote.getPort()),
-            "",
+            // The driver-config (e.g. the OPC UA "/milo" path) is the part of 
the connection
+            // URL after host:port; strict servers reject a Hello whose 
endpoint URL omits it,
+            // so it must be carried into the endpoint the driver advertises. 
Read from the
+            // TransportInstance interface so this works for any transport 
(tcp, tls, ...).
+            transportInstance.getDriverConfig(),
             configuration);
 
         messageCodec = new OpcuaMessageCodec(transportInstance, 
this::handleIncoming);
@@ -155,7 +159,7 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
         });
 
         this.conversation = new Conversation(this, driverContext, 
configuration);
-        this.channel = new SecureChannel(conversation, driverContext, 
configuration, getAuthentication());
+        this.channel = new SecureChannel(conversation, driverContext, 
configuration, resolveAuthentication());
 
         try {
             // Discovery only carries information needed for the encrypted 
modes
@@ -181,7 +185,13 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
         }
     }
 
-    private PlcAuthentication getAuthentication() {
+    private PlcAuthentication resolveAuthentication() {
+        // Authentication passed to getConnection(url, authentication) takes 
precedence over
+        // credentials embedded in the connection string.
+        PlcAuthentication passed = getAuthentication();
+        if (passed != null) {
+            return passed;
+        }
         if (configuration.getUsername() != null && configuration.getPassword() 
!= null) {
             return new 
PlcUsernamePasswordAuthentication(configuration.getUsername(), 
configuration.getPassword());
         }
@@ -196,7 +206,10 @@ public class OpcuaConnection extends 
ConnectionBase<OpcuaConfiguration> implemen
         }
         if (channel != null) {
             try {
-                channel.onDisconnect();
+                // Wait for CloseSession + CloseSecureChannel to actually 
reach the server
+                // before we tear down the socket below; otherwise the server 
leaks the
+                // session/channel and refuses new channels once its limit is 
hit.
+                channel.onDisconnect().get(configuration.getRequestTimeout(), 
TimeUnit.MILLISECONDS);
             } catch (Exception e) {
                 LOGGER.warn("Error during secure channel disconnect", e);
             }
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
index 8fe835de7a..2377068fd4 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java
@@ -107,6 +107,14 @@ public class OpcuaConfiguration implements Configuration {
     @Description("Password used to open trust store.")
     private String trustStorePassword;
 
+    @ConfigurationParameter("insecure-certificate-verification")
+    @BooleanDefaultValue(false)
+    @Description("Disables verification of the OPC UA server certificate, 
trusting any certificate the server presents.\n" +
+        "This is UNSAFE: it leaves the connection open to man-in-the-middle 
attacks and defeats the integrity/authenticity\n" +
+        "guarantees of a signed secure channel. Only enable it for local 
testing. In production, establish trust with\n" +
+        "`trust-store-file` (chain validation) or `server-certificate-file` 
(certificate pinning) instead.")
+    private boolean insecureCertificateVerification;
+
     // the discovered certificate when discovery is enabled
     private X509Certificate serverCertificate;
 
@@ -198,6 +206,20 @@ public class OpcuaConfiguration implements Configuration {
         return trustStorePassword == null ? null : 
trustStorePassword.toCharArray();
     }
 
+    public boolean isInsecureCertificateVerification() {
+        return insecureCertificateVerification;
+    }
+
+    /**
+     * The filesystem path of a user-supplied server certificate to pin trust 
to,
+     * or {@code null} if none was configured. Unlike {@link 
#getServerCertificate()},
+     * this never returns a certificate that was discovered over the 
(unauthenticated)
+     * discovery channel, so it is safe to use as a trust anchor.
+     */
+    public String getServerCertificateFile() {
+        return serverCertificateFile;
+    }
+
     public Limits getEncodingLimits() {
         return limits;
     }
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
index 8dfc7d1f2d..8c64128db0 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/AsymmetricEncryptionHandler.java
@@ -49,7 +49,7 @@ public class AsymmetricEncryptionHandler extends 
BaseEncryptionHandler {
         Signature signature = 
securityPolicy.getAsymmetricSignatureAlgorithm().getSignature();
         
signature.initVerify(conversation.getRemoteCertificate().getPublicKey());
         signature.update(message);
-        if (signature.verify(signatureData)) {
+        if (!signature.verify(signatureData)) {
             throw new IllegalArgumentException("Invalid signature");
         }
     }
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
index 005c615a1c..ed17e85a94 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/BaseEncryptionHandler.java
@@ -181,7 +181,12 @@ abstract class BaseEncryptionHandler {
             }
 
             if (chunk.isSigned()) {
-                verify(chunkBytes, chunk, messageLength);
+                // After decryption the body may have shrunk (for asymmetric 
RSA the
+                // cipher-text block is larger than the plain-text block), so 
the
+                // signature slice must be located relative to the post-decrypt
+                // length, not the original on-the-wire messageLength.
+                int decryptedMessageLength = SECURE_MESSAGE_HEADER_SIZE + 
chunk.getSecurityHeaderSize() + bodySize;
+                verify(chunkBytes, chunk, decryptedMessageLength);
             }
 
             int encryptionOverhead = getEncryptionOverhead(chunk, 
messageLength);
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 c953374b74..b8533a38db 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
@@ -30,6 +30,8 @@ import org.apache.plc4x.java.opcua.config.OpcuaConfiguration;
 import org.apache.plc4x.java.opcua.readwrite.PascalByteString;
 import org.apache.plc4x.java.opcua.security.CertificateVerifier;
 import org.apache.plc4x.java.opcua.security.PermissiveCertificateVerifier;
+import org.apache.plc4x.java.opcua.security.PinnedCertificateVerifier;
+import org.apache.plc4x.java.opcua.security.RejectingCertificateVerifier;
 import org.apache.plc4x.java.opcua.security.SecurityPolicy;
 import org.apache.plc4x.java.opcua.security.TrustStoreCertificateVerifier;
 import org.bouncycastle.jce.provider.BouncyCastleProvider;
@@ -80,7 +82,11 @@ public class OpcuaDriverContext {
     private X509Certificate serverCertificate;
     private PascalByteString thumbprint;
 
-    private CertificateVerifier certificateVerifier = new 
PermissiveCertificateVerifier();
+    // Secure by default: reject every server certificate until an explicit 
trust
+    // anchor (trust store or pinned server certificate) is configured, or the 
user
+    // explicitly opts out of verification. Anything more permissive by 
default would
+    // leave signed secure channels open to man-in-the-middle attacks.
+    private CertificateVerifier certificateVerifier = new 
RejectingCertificateVerifier();
 
 
     public void openKeyStore(OpcuaConfiguration configuration) throws 
IOException, GeneralSecurityException {
@@ -104,10 +110,41 @@ public class OpcuaDriverContext {
             thumbprint = new PascalByteString(sha1.length, sha1);
         }
 
+        certificateVerifier = buildCertificateVerifier(configuration);
+    }
+
+    /**
+     * Selects the server-certificate trust strategy, in order of precedence:
+     * <ol>
+     *   <li>{@code insecure-certificate-verification=true} &rarr; trust 
everything (unsafe, opt-in only);</li>
+     *   <li>a {@code trust-store-file} &rarr; validate the certificate chain 
against the trust store;</li>
+     *   <li>a {@code server-certificate-file} &rarr; pin trust to that exact 
certificate;</li>
+     *   <li>otherwise &rarr; fail closed and reject, since no trust anchor is 
available.</li>
+     * </ol>
+     * Note that the pinned certificate is read from the configured file only 
— a
+     * certificate learned over the unauthenticated discovery channel is never 
used
+     * as a trust anchor.
+     */
+    private CertificateVerifier buildCertificateVerifier(OpcuaConfiguration 
configuration)
+        throws IOException, GeneralSecurityException {
+        if (configuration.isInsecureCertificateVerification()) {
+            LOGGER.warn("OPC UA server certificate verification is DISABLED "
+                + "('insecure-certificate-verification=true'). The connection 
is vulnerable to "
+                + "man-in-the-middle attacks; do not use this in production.");
+            return new PermissiveCertificateVerifier();
+        }
         if (configuration.getTrustStoreFile() != null) {
             KeyStore trustStore = 
openKeyStore(configuration.getTrustStoreFile(), 
configuration.getTrustStoreType(), configuration.getTrustStorePassword());
-            certificateVerifier = new 
TrustStoreCertificateVerifier(trustStore);
+            return new TrustStoreCertificateVerifier(trustStore);
+        }
+        if (configuration.getServerCertificateFile() != null) {
+            LOGGER.info("Pinning OPC UA server certificate trust to {}", 
configuration.getServerCertificateFile());
+            return new 
PinnedCertificateVerifier(configuration.getServerCertificate());
         }
+        LOGGER.warn("No OPC UA trust anchor configured ('trust-store-file' or 
'server-certificate-file'); "
+            + "server certificates will be rejected. Set 
'insecure-certificate-verification=true' to bypass "
+            + "verification for local testing only.");
+        return new RejectingCertificateVerifier();
     }
 
     public String getHost() {
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 e18816f3dc..b6a7ad1c27 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
@@ -330,7 +330,19 @@ public class SecureChannel {
         });
     }
 
-    public void onDisconnect() {
+    /**
+     * Closes the session and the secure channel on the server. The returned 
future
+     * completes once the {@code CloseSession} has been acknowledged and the
+     * {@code CloseSecureChannel} has been handed to the wire, so callers must 
await it
+     * before tearing down the socket — otherwise the server never sees the 
close, leaks
+     * the session/channel, and eventually refuses new channels once its 
concurrent-channel
+     * limit is reached.
+     *
+     * <p>Note that {@code CloseSecureChannel} is not awaited for a reply: per 
the OPC UA
+     * spec the server simply closes the channel without responding, so we 
only ensure its
+     * bytes are flushed (which {@code requestChannelClose} does 
synchronously).</p>
+     */
+    public CompletableFuture<Void> onDisconnect() {
         LOGGER.info("Disconnecting");
 
         if (keepAlive != null) {
@@ -340,13 +352,17 @@ public class SecureChannel {
 
         RequestHeader requestHeader = conversation.createRequestHeader(50000L);
         CloseSessionRequest closeSessionRequest = new 
CloseSessionRequest(requestHeader, true);
-        conversation.submit(closeSessionRequest, 
CloseSessionResponse.class).thenAccept(responseMessage -> {
-            LOGGER.trace("Got Close Session Response Connection Response" + 
responseMessage);
-            onDisconnectCloseSecureChannel();
-        });
+        return conversation.submit(closeSessionRequest, 
CloseSessionResponse.class)
+            // Proceed to close the channel even if the session close 
failed/timed out;
+            // the important thing is that we still tell the server to drop 
the channel.
+            .handle((responseMessage, error) -> {
+                LOGGER.trace("Got Close Session Response {}", responseMessage);
+                return null;
+            })
+            .thenRun(this::sendCloseSecureChannel);
     }
 
-    private void onDisconnectCloseSecureChannel() {
+    private void sendCloseSecureChannel() {
         RequestHeader requestHeader = conversation.createRequestHeader();
         CloseSecureChannelRequest closeSecureChannelRequest = new 
CloseSecureChannelRequest(requestHeader);
 
@@ -363,6 +379,7 @@ public class SecureChannel {
             )
         );
 
+        // Fire-and-forget: the bytes are flushed synchronously; no response 
is expected.
         conversation.requestChannelClose(closeRequest);
     }
 
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
new file mode 100644
index 0000000000..93ed5edfc6
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifier.java
@@ -0,0 +1,50 @@
+/*
+ * 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
+ *
+ *   http://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.security;
+
+import java.security.MessageDigest;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Objects;
+
+/**
+ * Certificate verifier that pins trust to a single, explicitly configured 
server
+ * certificate (from {@code server-certificate-file}). The presented 
certificate
+ * is accepted only when its DER encoding is byte-for-byte identical to the 
pinned
+ * certificate. Comparison uses a constant-time check to avoid leaking 
information
+ * through timing.
+ */
+public class PinnedCertificateVerifier implements CertificateVerifier {
+
+    private final byte[] pinnedEncoded;
+
+    public PinnedCertificateVerifier(X509Certificate pinnedCertificate) throws 
CertificateEncodingException {
+        this.pinnedEncoded = Objects.requireNonNull(pinnedCertificate, 
"pinnedCertificate must not be null").getEncoded();
+    }
+
+    @Override
+    public void checkCertificateTrusted(X509Certificate certificate) throws 
CertificateException {
+        if (certificate == null || !MessageDigest.isEqual(pinnedEncoded, 
certificate.getEncoded())) {
+            throw new CertificateException("Server certificate does not match 
the pinned 'server-certificate-file'.");
+        }
+    }
+
+}
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
new file mode 100644
index 0000000000..8cc9ff1c56
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java
@@ -0,0 +1,43 @@
+/*
+ * 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
+ *
+ *   http://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.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+
+/**
+ * Fail-closed certificate verifier used as the secure default: it rejects 
every
+ * server certificate because no trust anchor has been configured. To establish
+ * trust, configure either a {@code trust-store-file} (chain validation) or a
+ * {@code server-certificate-file} (certificate pinning). As a last resort,
+ * certificate verification can be disabled entirely with
+ * {@code insecure-certificate-verification=true}, which is unsafe and leaves 
the
+ * connection open to man-in-the-middle attacks.
+ */
+public class RejectingCertificateVerifier implements CertificateVerifier {
+
+    @Override
+    public void checkCertificateTrusted(X509Certificate certificate) throws 
CertificateException {
+        throw new CertificateException("No trust anchor configured for OPC UA 
server certificate verification. "
+            + "Configure 'trust-store-file' or 'server-certificate-file' to 
establish trust, or set "
+            + "'insecure-certificate-verification=true' to disable 
verification (unsafe).");
+    }
+
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
new file mode 100644
index 0000000000..c16f0b80e0
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/KeystoreGenerator.java
@@ -0,0 +1,115 @@
+/*
+ * 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
+ *
+ *   http://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;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.security.GeneralSecurityException;
+import java.security.Key;
+import java.security.KeyPair;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.Base64;
+import java.util.Set;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateBuilder;
+import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
+
+/**
+ * Utility to generate server certificate - based on Eclipse Milo stuff.
+ */
+public class KeystoreGenerator {
+
+    private final String password;
+    private final KeyStore keyStore;
+    private final X509Certificate certificate;
+
+    public KeystoreGenerator(String password, int length, String 
applicationUri) {
+        this(password, length, applicationUri, "server-ai", "Milo Server");
+    }
+
+    public KeystoreGenerator(String password, int length, String 
applicationUri, String entryName, String commonName) {
+        this.password = password;
+        try {
+            this.keyStore = generate(password, length, applicationUri, 
entryName, commonName);
+
+            Key serverPrivateKey = keyStore.getKey(entryName, 
password.toCharArray());
+            if (serverPrivateKey instanceof PrivateKey) {
+                this.certificate = (X509Certificate) 
keyStore.getCertificate(entryName);
+            } else {
+                throw new IllegalStateException("Unexpected keystore entry, 
expected private key");
+            }
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private KeyStore generate(String password, int length, String 
applicationUri, String entryName, String commonName) throws Exception {
+        KeyStore keyStore = KeyStore.getInstance("PKCS12");
+        keyStore.load(null, password.toCharArray());
+        KeyPair keyPair = 
SelfSignedCertificateGenerator.generateRsaKeyPair(length);
+        SelfSignedCertificateBuilder builder = (new 
SelfSignedCertificateBuilder(
+            keyPair)).setCommonName(commonName)
+            .setOrganization("Apache Software Foundation")
+            .setOrganizationalUnit("PLC4X")
+            .setLocalityName("PLC4J")
+            .setStateName("CA")
+            .setCountryCode("US")
+            .setApplicationUri(applicationUri);
+
+        Set<String> hostnames = Set.of("127.0.0.1");
+
+        for (String hostname : hostnames) {
+            if (hostname.startsWith("\\d+\\.")) {
+                builder.addIpAddress(hostname);
+            } else {
+                builder.addDnsName(hostname);
+            }
+        }
+
+        X509Certificate certificate = builder.build();
+        keyStore.setKeyEntry(entryName, keyPair.getPrivate(), 
password.toCharArray(), new X509Certificate[]{ certificate });
+        return keyStore;
+    }
+
+    public KeyStore getKeyStore() {
+        return keyStore;
+    }
+
+    public X509Certificate getCertificate() {
+        return certificate;
+    }
+
+    public void writeKeyStoreTo(OutputStream stream) throws IOException, 
GeneralSecurityException {
+        keyStore.store(stream, password.toCharArray());
+        stream.flush();
+    }
+
+    public void writeCertificateTo(OutputStream stream) throws IOException, 
CertificateEncodingException {
+        String data = "-----BEGIN CERTIFICATE-----\n" +
+            Base64.getMimeEncoder(64, 
"\n".getBytes()).encodeToString(certificate.getEncoded()) + "\n" +
+            "-----END CERTIFICATE-----";
+
+        stream.write(data.getBytes());
+        stream.flush();
+    }
+
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
new file mode 100644
index 0000000000..817813fd54
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/MiloTestContainer.java
@@ -0,0 +1,64 @@
+/*
+ * 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
+ *
+ *   http://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;
+
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.images.builder.ImageFromDockerfile;
+
+public class MiloTestContainer extends GenericContainer<MiloTestContainer> {
+
+    private final static Logger logger = 
LoggerFactory.getLogger(MiloTestContainer.class);
+
+    private final static ImageFromDockerfile IMAGE = inlineImage();
+
+    public MiloTestContainer() {
+        super(IMAGE);
+
+        waitingFor(Wait.forLogMessage("Server started\\s*", 1))
+            // Uncomment below to debug Milo server
+            //.withStartupTimeout(Duration.ofMinutes(10))
+        ;
+        // Fixed 12686 -> 12686 mapping. The Milo server advertises its 
endpoints on
+        // localhost:12686 (its internal bind port), so the host port MUST 
equal 12686 —
+        // otherwise the client connects to a random mapped port, is 
redirected to the
+        // (unreachable) advertised localhost:12686 endpoint, and the 
handshake hangs.
+        addFixedExposedPort(12686, 12686);
+
+        // Uncomment below to enable server debug
+        //withEnv("JAVA_TOOL_OPTIONS", 
"-agentlib:jdwp=transport=dt_socket,address=*:8000,server=y,suspend=y");
+        //addFixedExposedPort(8000, 8000);
+    }
+
+    private static ImageFromDockerfile inlineImage() {
+        Path absolutePath = Paths.get(".").toAbsolutePath();
+        logger.info("Building milo server image from {}", absolutePath);
+        // Reuse the Docker layer cache across runs; the build context only 
changes when
+        // the milo jar or the compiled TestMiloServer classes change, so a 
cached build
+        // stays correct while turning a multi-minute rebuild into a 
near-instant one.
+        return new ImageFromDockerfile("plc4x-milo-test", false)
+            .withDockerfile(absolutePath.resolve("Dockerfile.test"));
+    }
+
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
new file mode 100644
index 0000000000..ffe90b0cba
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java
@@ -0,0 +1,697 @@
+/*
+ * 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;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.lang.reflect.Array;
+import java.net.URLEncoder;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.apache.plc4x.java.DefaultPlcDriverManager;
+import org.apache.plc4x.java.api.PlcConnection;
+import org.apache.plc4x.java.api.PlcConnectionManager;
+import org.apache.plc4x.java.api.PlcDriverManager;
+import 
org.apache.plc4x.java.api.authentication.PlcUsernamePasswordAuthentication;
+import org.apache.plc4x.java.api.exceptions.PlcUnsupportedDataTypeException;
+import org.apache.plc4x.java.api.messages.PlcReadRequest;
+import org.apache.plc4x.java.api.messages.PlcReadResponse;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest;
+import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse;
+import org.apache.plc4x.java.api.messages.PlcWriteRequest;
+import org.apache.plc4x.java.api.messages.PlcWriteResponse;
+import org.apache.plc4x.java.api.types.PlcResponseCode;
+import org.apache.plc4x.java.opcua.security.MessageSecurity;
+import org.apache.plc4x.java.opcua.security.SecurityPolicy;
+import org.apache.plc4x.java.opcua.tag.OpcuaTag;
+import org.assertj.core.api.Condition;
+import org.assertj.core.api.SoftAssertions;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.math.BigInteger;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.ExecutionException;
+import java.util.stream.Stream;
+import org.testcontainers.containers.output.Slf4jLogConsumer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+
+import static java.util.Map.entry;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.fail;
+
+@Testcontainers(disabledWithoutDocker = true)
+public class OpcuaPlcDriverTest {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(OpcuaPlcDriverTest.class);
+
+    private static final String APPLICATION_URI = "urn:org.apache:plc4x";
+    private static final KeystoreGenerator SERVER_KEY_STORE_GENERATOR = new 
KeystoreGenerator("password", 2048, APPLICATION_URI);
+    private static final KeystoreGenerator CLIENT_KEY_STORE_GENERATOR = new 
KeystoreGenerator("changeit", 2048, APPLICATION_URI, "plc4x_plus_milo", 
"client");
+
+    private static final File SECURITY_DIR;
+    private static final File CLIENT_KEY_STORE;
+    // The server's certificate, exported so the client can pin trust to it. 
Since the
+    // driver now rejects server certificates by default (no permissive 
fallback), every
+    // secured connection needs an explicit trust anchor.
+    private static final File SERVER_CERTIFICATE;
+
+    // Provision the server keystore / client material BEFORE the container 
starts. The
+    // container is static (see below) so the Testcontainers extension starts 
it during
+    // its beforeAll callback, which runs before any @BeforeAll method — hence 
the setup
+    // lives in this static initializer rather than in @BeforeAll.
+    static {
+        try {
+            Path tempDirectory = 
Files.createTempDirectory("plc4x_opcua_client");
+
+            SECURITY_DIR = new File(tempDirectory.toFile().getAbsolutePath(), 
"server/security");
+            File pkiDir = new File(SECURITY_DIR, "pki");
+            File trustedCerts = new File(pkiDir, "trusted/certs");
+            if (!pkiDir.mkdirs() || !trustedCerts.mkdirs()) {
+                throw new IllegalStateException("Could not start test - 
missing permissions to create temporary files");
+            }
+
+            // pre-provisioned server certificate (keystore the Milo server 
loads on boot)
+            try (FileOutputStream bos = new FileOutputStream(new 
File(pkiDir.getParentFile(), "example-server.pfx"))) {
+                SERVER_KEY_STORE_GENERATOR.writeKeyStoreTo(bos);
+            }
+
+            // export the server certificate so secured client connections can 
pin trust to it
+            SERVER_CERTIFICATE = Files.createTempFile("plc4x_opcua_server_", 
".crt").toAbsolutePath().toFile();
+            try (FileOutputStream outputStream = new 
FileOutputStream(SERVER_CERTIFICATE)) {
+                SERVER_KEY_STORE_GENERATOR.writeCertificateTo(outputStream);
+            }
+
+            // pre-provisioned client certificate, doing it here because 
server might be slow in picking up them, and we don't want to wait with our 
tests
+            CLIENT_KEY_STORE = Files.createTempFile("plc4x_opcua_client_", 
".p12").toAbsolutePath().toFile();
+            try (FileOutputStream outputStream = new 
FileOutputStream(CLIENT_KEY_STORE)) {
+                CLIENT_KEY_STORE_GENERATOR.writeKeyStoreTo(outputStream);
+            }
+            try (FileOutputStream outputStream = new FileOutputStream(new 
File(trustedCerts, "plc4x.crt"))) {
+                CLIENT_KEY_STORE_GENERATOR.writeCertificateTo(outputStream);
+            }
+        } catch (Exception e) {
+            throw new ExceptionInInitializerError(e);
+        }
+    }
+
+    // Static so the Milo server boots ONCE for the whole class rather than 
being torn
+    // down and rebuilt before every test method (which is what an instance 
@Container does).
+    @Container
+    public static final MiloTestContainer milo = new MiloTestContainer()
+        //.withCreateContainerCmdModifier(cmd -> 
cmd.withHostName("test-opcua-server"))
+        .withLogConsumer(new Slf4jLogConsumer(LOGGER))
+        .withFileSystemBind(SECURITY_DIR.getAbsolutePath(), 
"/tmp/server/security")
+        //.withEnv("JAVA_TOOL_OPTIONS", 
"-agentlib:jdwp=transport=dt_socket,address=*:8000,server=y,suspend=y")
+//        .withCommand("java -cp '/opt/milo/*:/opt/milo/' 
org.eclipse.milo.examples.server.TestMiloServer")
+        ;
+
+    // Read only variables of milo example server of version 3.6
+    private static final String BOOL_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Boolean";
+    private static final String BYTE_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Byte";
+    private static final String DOUBLE_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Double";
+    private static final String FLOAT_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Float";
+    private static final String INT16_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Int16";
+    private static final String INT32_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Int32";
+    private static final String INT64_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Int64";
+    private static final String INTEGER_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Integer";
+    private static final String SBYTE_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/SByte";
+    private static final String STRING_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/String";
+    private static final String UINT16_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/UInt16";
+    private static final String UINT32_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/UInt32";
+    private static final String UINT64_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/UInt64";
+    private static final String UINTEGER_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/UInteger";
+    private static final String DOES_NOT_EXIST_IDENTIFIER_READ_WRITE = 
"ns=2;i=12512623";
+    private static final String DOES_NOT_EXISTS_TAG_NAME = "DoesNotExists"; // 
tag name
+
+    // At the moment not used in PLC4X or in the OPC UA driver
+    private static final String BYTE_STRING_IDENTIFIER_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/ByteString";
+    private static final String DATE_TIME_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/DateTime";
+    private static final String DURATION_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Duration";
+    private static final String GUID_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Guid";
+    private static final String LOCALIZED_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/LocalizedText";
+    private static final String NODE_ID_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/NodeId";
+    private static final String QUALIFIED_NAM_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/QualifiedName";
+    private static final String UTC_TIME_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/UtcTime";
+    private static final String VARIANT_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/Variant";
+    private static final String XML_ELEMENT_READ_WRITE = 
"ns=2;s=HelloWorld/ScalarTypes/XmlElement";
+
+    //Arrays
+    private static final String BOOL_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/BooleanArray";
+    //private static final String BYTE_STRING_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/ByteStringArray";
+    private static final String BYTE_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/ByteArray";
+    private static final String DOUBLE_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/DoubleArray";
+    private static final String FLOAT_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/FloatArray";
+    private static final String INT16_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/Int16Array";
+    private static final String INT32_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/Int32Array";
+    private static final String INT64_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/Int64Array";
+    private static final String INTEGER_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/IntegerArray";
+    private static final String SBYTE_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/SByteArray";
+    private static final String STRING_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/StringArray";
+    private static final String UINT16_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/UInt16Array";
+    private static final String UINT32_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/UInt32Array";
+    private static final String UINT64_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/UInt64Array";
+    private static final String DATE_TIME_ARRAY_IDENTIFIER = 
"ns=2;s=HelloWorld/ArrayTypes/DateTimeArray";
+
+    //Restricted
+    public static final String STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE = 
"ns=2;s=HelloWorld/OnlyAdminCanRead/String";
+
+    // Address of local milo server, since it comes from test container its 
hostname and port is not static
+    private final String miloLocalAddress = "%s:%d/milo";
+    //Tcp pattern of OPC UA
+    private final String opcPattern = "opcua:tcp://";
+
+    private static final String PARAM_SECTION_DIVIDER = "?";
+    private static final String PARAM_DIVIDER = "&";
+
+    private final String discoveryValidParamTrue = "discovery=true";
+    private final String discoveryValidParamFalse = "discovery=false";
+    private final String discoveryCorruptedParamWrongValueNum = "discovery=1";
+    private final String discoveryCorruptedParamWrongName = "diskovery=false";
+
+    private String tcpConnectionAddress;
+    private List<String> connectionStringValidSet;
+
+    final List<String> discoveryParamValidSet = 
List.of(discoveryValidParamTrue, discoveryValidParamFalse);
+    List<String> discoveryParamCorruptedSet = 
List.of(discoveryCorruptedParamWrongValueNum, discoveryCorruptedParamWrongName);
+
+    @BeforeEach
+    public void startUp() throws Exception {
+        tcpConnectionAddress = String.format(opcPattern + miloLocalAddress, 
milo.getHost(), milo.getMappedPort(12686)) + "?endpoint-port=12686";
+        connectionStringValidSet = List.of(tcpConnectionAddress);
+    }
+
+    @Nested
+    class SmokeTest {
+        @Test
+        public void manyReconnectionsWithSingleSubscription() throws Exception 
{
+            PlcDriverManager driverManager = new DefaultPlcDriverManager();
+            PlcConnectionManager connectionManager = 
driverManager.getConnectionManager();
+
+            for (int i = 0; i < 3; i++) {
+                try (PlcConnection connection = 
connectionManager.getConnection(tcpConnectionAddress)) {
+
+                    PlcSubscriptionRequest request = 
connection.subscriptionRequestBuilder()
+                            .addChangeOfStateTag("Demo", 
OpcuaTag.of(INTEGER_IDENTIFIER_READ_WRITE))
+                            .build();
+
+                    PlcSubscriptionResponse response = 
request.execute().get(60, TimeUnit.SECONDS);
+                    
assertThat(response.getResponseCode("Demo")).isEqualTo(PlcResponseCode.OK);
+
+                    connection.unsubscriptionRequestBuilder()
+                            .addHandles(response.getSubscriptionHandles())
+                            .build()
+                            .execute();
+                }
+            }
+        }
+        @Test
+        public void manySubscriptionsOnSingleConnection() throws Exception {
+            final int numberOfSubscriptions = 3;
+
+            PlcDriverManager driverManager = new DefaultPlcDriverManager();
+            PlcConnectionManager connectionManager = 
driverManager.getConnectionManager();
+
+            ArrayList<PlcSubscriptionResponse> plcSubscriptionResponses = new 
ArrayList<>();
+            ConcurrentLinkedDeque<PlcSubscriptionEvent> events = new 
ConcurrentLinkedDeque<>();
+
+            try (PlcConnection connection = 
connectionManager.getConnection(tcpConnectionAddress)) {
+                for (int i = 0; i < numberOfSubscriptions; i++) {
+                    PlcSubscriptionRequest request = 
connection.subscriptionRequestBuilder()
+                            .addChangeOfStateTag("Demo", 
OpcuaTag.of(INTEGER_IDENTIFIER_READ_WRITE))
+                            .build();
+
+                    PlcSubscriptionResponse response = 
request.execute().get(60, TimeUnit.SECONDS);
+                    
assertThat(response.getResponseCode("Demo")).isEqualTo(PlcResponseCode.OK);
+
+                    plcSubscriptionResponses.add(response);
+
+                    response.getSubscriptionHandles().forEach(handle -> 
handle.register(events::add));
+                }
+
+                for (int i = 0; i < 60; i++) {
+                    if (events.size() == numberOfSubscriptions) {
+                        break;
+                    }
+                    try {
+                        Thread.sleep(1000);
+                    } catch (InterruptedException e) {
+                        throw new RuntimeException(e);
+                    }
+                }
+                assertThat(events.size()).isEqualTo(numberOfSubscriptions);
+
+                for (PlcSubscriptionResponse response : 
plcSubscriptionResponses) {
+                    connection.unsubscriptionRequestBuilder()
+                            .addHandles(response.getSubscriptionHandles())
+                            .build()
+                            .execute();
+                }
+            }
+        }
+    }
+
+    @Nested
+    class ConnectionRelated {
+        @TestFactory
+        Stream<DynamicNode> connectionNoParams() {
+            return connectionStringValidSet.stream()
+                .map(connectionString -> 
DynamicTest.dynamicTest(connectionString, () -> {
+                    PlcConnection opcuaConnection = new 
DefaultPlcDriverManager().getConnection(connectionString);
+                    Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                    assertThat(opcuaConnection).is(is_connected);
+                    opcuaConnection.close();
+                    assertThat(opcuaConnection).isNot(is_connected);
+                }))
+                .map(DynamicNode.class::cast);
+        }
+
+        @TestFactory
+        Stream<DynamicNode> connectionWithDiscoveryParam() throws Exception {
+            return connectionStringValidSet.stream()
+                .map(connectionAddress -> 
DynamicContainer.dynamicContainer(connectionAddress, () ->
+                    discoveryParamValidSet.stream().map(discoveryParam -> 
DynamicTest.dynamicTest(discoveryParam, () -> {
+                            String connectionString = connectionAddress + 
PARAM_DIVIDER + discoveryParam;
+                            PlcConnection opcuaConnection = new 
DefaultPlcDriverManager().getConnection(connectionString);
+                            Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                            assertThat(opcuaConnection).is(is_connected);
+                            opcuaConnection.close();
+                            assertThat(opcuaConnection).isNot(is_connected);
+                        }))
+                        .map(DynamicNode.class::cast)
+                        .iterator()))
+                .map(DynamicNode.class::cast);
+        }
+
+        @Test
+        void connectionWithUrlAuthentication() throws Exception {
+            DefaultPlcDriverManager driverManager = new 
DefaultPlcDriverManager();
+            try (PlcConnection opcuaConnection = 
driverManager.getConnection(tcpConnectionAddress + 
"&username=admin&password=password2")) {
+                Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                assertThat(opcuaConnection).is(is_connected);
+
+                PlcReadRequest.Builder builder = 
opcuaConnection.readRequestBuilder()
+                        .addTagAddress("String", 
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+                PlcReadRequest request = builder.build();
+                PlcReadResponse response = request.execute().get();
+
+                
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+            }
+        }
+
+        @Test
+        void connectionWithPlcAuthentication() throws Exception {
+            DefaultPlcDriverManager driverManager = new 
DefaultPlcDriverManager();
+            try (PlcConnection opcuaConnection = 
driverManager.getConnection(tcpConnectionAddress,
+                    new PlcUsernamePasswordAuthentication("admin", 
"password2"))) {
+                Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                assertThat(opcuaConnection).is(is_connected);
+
+                PlcReadRequest.Builder builder = 
opcuaConnection.readRequestBuilder()
+                        .addTagAddress("String", 
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+                PlcReadRequest request = builder.build();
+                PlcReadResponse response = request.execute().get();
+
+                
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+            }
+        }
+
+        @Test
+        void connectionWithPlcAuthenticationOverridesUrlParam() throws 
Exception {
+            DefaultPlcDriverManager driverManager = new 
DefaultPlcDriverManager();
+            try (PlcConnection opcuaConnection = 
driverManager.getConnection(tcpConnectionAddress + 
"&username=user&password=password1",
+                    new PlcUsernamePasswordAuthentication("admin", 
"password2"))) {
+                Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                assertThat(opcuaConnection).is(is_connected);
+
+                PlcReadRequest.Builder builder = 
opcuaConnection.readRequestBuilder()
+                        .addTagAddress("String", 
STRING_IDENTIFIER_ONLY_ADMIN_READ_WRITE);
+
+                PlcReadRequest request = builder.build();
+                PlcReadResponse response = request.execute().get();
+
+                
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+            }
+        }
+
+        @Test
+        void staticConfig() throws Exception {
+            DefaultPlcDriverManager driverManager = new 
DefaultPlcDriverManager();
+
+            String options = params(
+                entry("discovery", "false"),
+                entry("server-certificate-file", 
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+                entry("key-store-file", 
CLIENT_KEY_STORE.toString().replace("\\", "/")), // handle windows paths
+                entry("key-store-password", "changeit"),
+                entry("key-store-type", "pkcs12"),
+                entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+                entry("message-security", MessageSecurity.SIGN.name())
+            );
+            try (PlcConnection opcuaConnection = 
driverManager.getConnection(tcpConnectionAddress + PARAM_DIVIDER
+                + options)) {
+                Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+                assertThat(opcuaConnection).is(is_connected);
+
+                PlcReadRequest.Builder builder = 
opcuaConnection.readRequestBuilder()
+                    .addTagAddress("String", STRING_IDENTIFIER_READ_WRITE);
+
+                PlcReadRequest request = builder.build();
+                PlcReadResponse response = request.execute().get();
+
+                
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+            }
+        }
+
+        @Test
+        void securedConnectionWithoutTrustAnchorIsRejected() {
+            // No trust-store-file and no server-certificate-file: the driver 
must fail
+            // closed rather than blindly trusting whatever certificate the 
server presents.
+            String options = params(
+                entry("discovery", "false"),
+                entry("key-store-file", 
CLIENT_KEY_STORE.toString().replace("\\", "/")),
+                entry("key-store-password", "changeit"),
+                entry("key-store-type", "pkcs12"),
+                entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+                entry("message-security", MessageSecurity.SIGN.name())
+            );
+            assertThatThrownBy(() ->
+                new 
DefaultPlcDriverManager().getConnection(tcpConnectionAddress + PARAM_DIVIDER + 
options))
+                .isInstanceOf(Exception.class);
+        }
+
+        @Test
+        void securedConnectionWithInsecureVerificationConnects() throws 
Exception {
+            // With insecure-certificate-verification the driver must use the 
permissive verifier
+            // (it wins over pinning), so trust is not checked. The server 
certificate is still
+            // supplied because an encrypted policy needs the server's public 
key to encrypt the
+            // OpenSecureChannel; only its trust verification is bypassed here.
+            String options = params(
+                entry("discovery", "false"),
+                entry("key-store-file", 
CLIENT_KEY_STORE.toString().replace("\\", "/")),
+                entry("key-store-password", "changeit"),
+                entry("key-store-type", "pkcs12"),
+                entry("server-certificate-file", 
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+                entry("insecure-certificate-verification", "true"),
+                entry("security-policy", SecurityPolicy.Basic256Sha256.name()),
+                entry("message-security", MessageSecurity.SIGN.name())
+            );
+            try (PlcConnection opcuaConnection =
+                     new 
DefaultPlcDriverManager().getConnection(tcpConnectionAddress + PARAM_DIVIDER + 
options)) {
+                assertThat(opcuaConnection.isConnected()).isTrue();
+
+                PlcReadResponse response = opcuaConnection.readRequestBuilder()
+                    .addTagAddress("String", STRING_IDENTIFIER_READ_WRITE)
+                    .build().execute().get();
+
+                
assertThat(response.getResponseCode("String")).isEqualTo(PlcResponseCode.OK);
+            }
+        }
+    }
+
+    @Nested
+    class readWrite {
+        Map<String, Entry<String, Object>> tags = Map.ofEntries(
+            entry("Bool", entry(BOOL_IDENTIFIER_READ_WRITE, true)),
+            entry("Byte", entry(BYTE_IDENTIFIER_READ_WRITE + ";BYTE", (short) 
3)),
+            entry("Double", entry(DOUBLE_IDENTIFIER_READ_WRITE, 0.5d)),
+            entry("Float", entry(FLOAT_IDENTIFIER_READ_WRITE, 0.5f)),
+            entry("Int16", entry(INT16_IDENTIFIER_READ_WRITE + ";INT", 1)),
+            entry("Int32", entry(INT32_IDENTIFIER_READ_WRITE, 42)),
+            entry("Int64", entry(INT64_IDENTIFIER_READ_WRITE, 42L)),
+            entry("Integer", entry(INTEGER_IDENTIFIER_READ_WRITE, -127)),
+            //entry("SByte", entry(SBYTE_IDENTIFIER_READ_WRITE, )),
+            entry("String", entry(STRING_IDENTIFIER_READ_WRITE, "Hello 
Toddy!")),
+            entry("UInt16", entry(UINT16_IDENTIFIER_READ_WRITE + ";UINT", 
65535)),
+            entry("UInt32", entry(UINT32_IDENTIFIER_READ_WRITE + ";UDINT", 
101010101L)),
+            entry("UInt64", entry(UINT64_IDENTIFIER_READ_WRITE + ";ULINT", new 
BigInteger("1337"))),
+            entry("UInteger", entry(UINTEGER_IDENTIFIER_READ_WRITE + ";UDINT", 
102020202L)),
+            entry("BooleanArray", entry(BOOL_ARRAY_IDENTIFIER, new 
boolean[]{true, true, true, true, true})),
+            // entry("ByteStringArray", entry(BYTE_STRING_ARRAY_IDENTIFIER, 
null)),
+            entry("ByteArray", entry(BYTE_ARRAY_IDENTIFIER + ";BYTE", new 
Short[]{1, 100, 100, 255, 123})),
+            entry("DoubleArray", entry(DOUBLE_ARRAY_IDENTIFIER, new 
Double[]{1.0, 2.0, 3.0, 4.0, 5.0})),
+            entry("FloatArray", entry(FLOAT_ARRAY_IDENTIFIER, new 
Float[]{1.0F, 2.0F, 3.0F, 4.0F, 5.0F})),
+            entry("Int16Array", entry(INT16_ARRAY_IDENTIFIER, new Short[]{1, 
2, 3, 4, 5})),
+            entry("Int32Array", entry(INT32_ARRAY_IDENTIFIER, new Integer[]{1, 
2, 3, 4, 5})),
+            entry("Int64Array", entry(INT64_ARRAY_IDENTIFIER, new Long[]{1L, 
2L, 3L, 4L, 5L})),
+            entry("IntegerArray", entry(INT32_ARRAY_IDENTIFIER, new 
Integer[]{1, 2, 3, 4, 5})),
+            entry("SByteArray", entry(SBYTE_ARRAY_IDENTIFIER, new Byte[]{1, 2, 
3, 4, 5})),
+            entry("StringArray", entry(STRING_ARRAY_IDENTIFIER, new 
String[]{"1", "2", "3", "4", "5"})),
+            entry("UInt16Array", entry(UINT16_ARRAY_IDENTIFIER + ";UINT", new 
Short[]{1, 2, 3, 4, 5})),
+            entry("UInt32Array", entry(UINT32_ARRAY_IDENTIFIER + ";UDINT", new 
Integer[]{1, 2, 3, 4, 5})),
+            entry("UInt64Array", entry(UINT64_ARRAY_IDENTIFIER + ";ULINT", new 
Long[]{1L, 2L, 3L, 4L, 5L})),
+            entry(DOES_NOT_EXISTS_TAG_NAME, 
entry(DOES_NOT_EXIST_IDENTIFIER_READ_WRITE, "11"))
+        );
+
+        @ParameterizedTest
+        
@MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getConnectionSecurityPolicies")
+        public void readVariables(SecurityPolicy policy, MessageSecurity 
messageSecurity) throws Exception {
+            String connectionString = getConnectionString(policy, 
messageSecurity);
+            PlcConnection opcuaConnection = new 
DefaultPlcDriverManager().getConnection(connectionString);
+            Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+            assertThat(opcuaConnection).is(is_connected);
+
+            PlcReadRequest.Builder builder = 
opcuaConnection.readRequestBuilder();
+            tags.forEach((tagName, tagEntry) -> builder.addTagAddress(tagName, 
tagEntry.getKey()));
+            PlcReadRequest request = builder.build();
+            PlcReadResponse response = request.execute().get(30, 
TimeUnit.SECONDS);
+
+            SoftAssertions softly = new SoftAssertions();
+            tags.keySet().forEach(tag -> {
+                if (DOES_NOT_EXISTS_TAG_NAME.equals(tag)) {
+                    softly.assertThat(response.getResponseCode(tag))
+                        .describedAs("Tag %s should not exist and return 
NOT_FOUND status", tag)
+                        .isEqualTo(PlcResponseCode.NOT_FOUND);
+                } else {
+                    softly.assertThat(response.getResponseCode(tag))
+                        .describedAs("Tag %s should exist and return OK 
status", tag)
+                        .isEqualTo(PlcResponseCode.OK);
+                }
+            });
+            softly.assertAll();
+
+            opcuaConnection.close();
+            assertThat(opcuaConnection.isConnected()).isFalse();
+        }
+
+        @ParameterizedTest
+        
@MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getConnectionSecurityPolicies")
+        public void writeVariables(SecurityPolicy policy, MessageSecurity 
messageSecurity) throws Exception {
+            String connectionString = getConnectionString(policy, 
messageSecurity);
+            PlcConnection opcuaConnection = new 
DefaultPlcDriverManager().getConnection(connectionString);
+            Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+            assertThat(opcuaConnection).is(is_connected);
+
+            PlcWriteRequest.Builder builder = 
opcuaConnection.writeRequestBuilder();
+            tags.forEach((tagName, tagEntry) -> {
+                try {
+                    Object value = tagEntry.getValue();
+                    if (value.getClass().isArray()) {
+                        Object[] values = new Object[Array.getLength(value)];
+                        for (int index = 0; index < Array.getLength(value); 
index++) {
+                            values[index] = Array.get(value, index);
+                        }
+                        builder.addTagAddress(tagName, tagEntry.getKey(), 
values);
+                    } else {
+                        builder.addTagAddress(tagName, tagEntry.getKey(), 
value);
+                    }
+                } catch (PlcUnsupportedDataTypeException e) {
+                    fail(e.toString());
+                }
+            });
+            PlcWriteRequest request = builder.build();
+            PlcWriteResponse response = request.execute().get(30, 
TimeUnit.SECONDS);
+
+            SoftAssertions softly = new SoftAssertions();
+            tags.keySet().forEach(tag -> {
+                if (DOES_NOT_EXISTS_TAG_NAME.equals(tag)) {
+                    
softly.assertThat(response.getResponseCode(DOES_NOT_EXISTS_TAG_NAME))
+                        .describedAs("Tag %s should not exist and return 
NOT_FOUND status", tag)
+                        .isEqualTo(PlcResponseCode.NOT_FOUND);
+                } else {
+                    softly.assertThat(response.getResponseCode(tag))
+                        .describedAs("Tag %s should exist and return OK 
status", tag)
+                        .isEqualTo(PlcResponseCode.OK);
+                }
+            });
+            softly.assertAll();
+
+            opcuaConnection.close();
+            assert !opcuaConnection.isConnected();
+        }
+
+    }
+
+    /*
+        Test added to test the synchronized TransactionHandler. (This was 
disabled before being enabled again so it might be a candidate for those tests 
not running properly on different platforms)
+     */
+    @Test
+    public void multipleThreads() throws Exception {
+        class ReadWorker extends Thread {
+            private final PlcConnection connection;
+            private final CountDownLatch latch;
+
+            public ReadWorker(PlcConnection opcuaConnection, CountDownLatch 
latch) {
+                this.connection = opcuaConnection;
+                this.latch = latch;
+            }
+
+            @Override
+            public void run() {
+                try {
+                    PlcReadRequest.Builder read_builder = 
connection.readRequestBuilder();
+                    read_builder.addTagAddress("Bool", 
BOOL_IDENTIFIER_READ_WRITE);
+                    PlcReadRequest read_request = read_builder.build();
+
+                    for (int i = 0; i < 10; i++) {
+                        PlcReadResponse read_response = 
read_request.execute().get();
+                        
assertThat(read_response.getResponseCode("Bool")).isEqualTo(PlcResponseCode.OK);
+                    }
+                } catch (ExecutionException e) {
+                    LOGGER.error("run aborted", e);
+                } catch (InterruptedException e) {
+                    LOGGER.error("thread interrupted", e);
+                    Thread.currentThread().interrupt();
+                } finally {
+                    this.latch.countDown();
+                }
+            }
+        }
+
+        class WriteWorker extends Thread {
+            private final PlcConnection connection;
+            private final CountDownLatch latch;
+
+            public WriteWorker(PlcConnection opcuaConnection, CountDownLatch 
latch) {
+                this.connection = opcuaConnection;
+                this.latch = latch;
+            }
+
+            @Override
+            public void run() {
+                try {
+                    PlcWriteRequest.Builder write_builder = 
connection.writeRequestBuilder();
+                    write_builder.addTagAddress("Bool", 
BOOL_IDENTIFIER_READ_WRITE, true);
+                    PlcWriteRequest write_request = write_builder.build();
+
+                    for (int i = 0; i < 10; i++) {
+                        PlcWriteResponse write_response = 
write_request.execute().get();
+                        
assertThat(write_response.getResponseCode("Bool")).isEqualTo(PlcResponseCode.OK);
+                    }
+                } catch (ExecutionException e) {
+                    LOGGER.error("run aborted", e);
+                } catch (InterruptedException e) {
+                    LOGGER.error("thread interrupted", e);
+                    Thread.currentThread().interrupt();
+                } finally {
+                    this.latch.countDown();
+                }
+            }
+        }
+
+
+        PlcConnection opcuaConnection = new 
DefaultPlcDriverManager().getConnection(tcpConnectionAddress);
+        Condition<PlcConnection> is_connected = new 
Condition<>(PlcConnection::isConnected, "is connected");
+        assertThat(opcuaConnection).is(is_connected);
+
+        CountDownLatch latch = new CountDownLatch(2);
+        ReadWorker read_worker = new ReadWorker(opcuaConnection, latch);
+        WriteWorker write_worker = new WriteWorker(opcuaConnection, latch);
+        read_worker.start();
+        write_worker.start();
+
+        latch.await();
+
+        opcuaConnection.close();
+        assert !opcuaConnection.isConnected();
+    }
+
+    private String getConnectionString(SecurityPolicy policy, MessageSecurity 
messageSecurity) throws Exception {
+        switch (policy) {
+            case NONE:
+                return tcpConnectionAddress;
+
+            case Basic256:
+            case Basic128Rsa15:
+            case Basic256Sha256:
+            case Aes128_Sha256_RsaOaep:
+            case Aes256_Sha256_RsaPss:
+                String connectionParams = params(
+                    // Trust is pinned via server-certificate-file below, so 
skip discovery.
+                    entry("discovery", "false"),
+                    entry("key-store-file", 
CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/")), // handle 
windows paths
+                    entry("key-store-password", "changeit"),
+                    entry("key-store-type", "pkcs12"),
+                    // Pin trust to the server certificate; the driver rejects 
unknown certs by default.
+                    entry("server-certificate-file", 
SERVER_CERTIFICATE.toString().replace("\\", "/")),
+                    entry("security-policy", policy.name()),
+                    entry("message-security", messageSecurity.name())
+                );
+
+                return tcpConnectionAddress + PARAM_DIVIDER + connectionParams;
+            default:
+                throw new IllegalStateException();
+        }
+    }
+
+    private static Stream<Arguments> getConnectionSecurityPolicies() {
+        return Stream.of(
+            Arguments.of(SecurityPolicy.NONE, MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.NONE, MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.NONE, MessageSecurity.SIGN_ENCRYPT),
+            //Arguments.of(SecurityPolicy.Basic256Sha256, 
MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.Basic256Sha256, MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.Basic256Sha256, 
MessageSecurity.SIGN_ENCRYPT),
+            //Arguments.of(SecurityPolicy.Basic256, MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.Basic256, MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.Basic256, 
MessageSecurity.SIGN_ENCRYPT),
+            //Arguments.of(SecurityPolicy.Basic128Rsa15, MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.Basic128Rsa15, MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.Basic128Rsa15, 
MessageSecurity.SIGN_ENCRYPT),
+            //Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep, 
MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep, 
MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.Aes128_Sha256_RsaOaep, 
MessageSecurity.SIGN_ENCRYPT),
+            //Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss, 
MessageSecurity.NONE),
+            Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss, 
MessageSecurity.SIGN),
+            Arguments.of(SecurityPolicy.Aes256_Sha256_RsaPss, 
MessageSecurity.SIGN_ENCRYPT)
+        );
+    }
+
+    private static String params(Entry<String, String> ... entries) {
+        return Stream.of(entries)
+            .map(entry -> entry.getKey() + "=" + 
URLEncoder.encode(entry.getValue(), Charset.defaultCharset()))
+            .collect(Collectors.joining(PARAM_DIVIDER));
+    }
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
new file mode 100644
index 0000000000..1b5d56eae6
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/PinnedCertificateVerifierTest.java
@@ -0,0 +1,54 @@
+/*
+ * 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.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import org.apache.plc4x.java.opcua.TestCertificateGenerator;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class PinnedCertificateVerifierTest {
+
+    @Test
+    void acceptsTheExactPinnedCertificate() throws Exception {
+        X509Certificate pinned = TestCertificateGenerator.generate(2048, 
"CN=server", 3600).getValue();
+        PinnedCertificateVerifier verifier = new 
PinnedCertificateVerifier(pinned);
+        assertThatCode(() -> 
verifier.checkCertificateTrusted(pinned)).doesNotThrowAnyException();
+    }
+
+    @Test
+    void rejectsADifferentCertificate() throws Exception {
+        X509Certificate pinned = TestCertificateGenerator.generate(2048, 
"CN=server", 3600).getValue();
+        X509Certificate impostor = TestCertificateGenerator.generate(2048, 
"CN=server", 3600).getValue();
+        PinnedCertificateVerifier verifier = new 
PinnedCertificateVerifier(pinned);
+        assertThatThrownBy(() -> verifier.checkCertificateTrusted(impostor))
+            .isInstanceOf(CertificateException.class);
+    }
+
+    @Test
+    void rejectsNullCertificate() throws Exception {
+        X509Certificate pinned = TestCertificateGenerator.generate(2048, 
"CN=server", 3600).getValue();
+        PinnedCertificateVerifier verifier = new 
PinnedCertificateVerifier(pinned);
+        assertThatThrownBy(() -> verifier.checkCertificateTrusted(null))
+            .isInstanceOf(CertificateException.class);
+    }
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
new file mode 100644
index 0000000000..55b1e9852b
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifierTest.java
@@ -0,0 +1,42 @@
+/*
+ * 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.security;
+
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import org.apache.plc4x.java.opcua.TestCertificateGenerator;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class RejectingCertificateVerifierTest {
+
+    @Test
+    void rejectsAnyCertificate() {
+        X509Certificate certificate = TestCertificateGenerator.generate(2048, 
"CN=server", 3600).getValue();
+        assertThatThrownBy(() -> new 
RejectingCertificateVerifier().checkCertificateTrusted(certificate))
+            .isInstanceOf(CertificateException.class);
+    }
+
+    @Test
+    void rejectsNullCertificate() {
+        assertThatThrownBy(() -> new 
RejectingCertificateVerifier().checkCertificateTrusted(null))
+            .isInstanceOf(CertificateException.class);
+    }
+}
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
 
b/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
index e2f19386df..c6e946cf48 100644
--- 
a/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
+++ 
b/plc4j/drivers/opcua/src/test/java/org/eclipse/milo/examples/server/TestMiloServer.java
@@ -39,6 +39,7 @@ import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.eclipse.milo.opcua.stack.core.Stack;
 import org.eclipse.milo.opcua.sdk.server.OpcUaServer;
 import org.eclipse.milo.opcua.sdk.server.api.config.OpcUaServerConfig;
 import org.eclipse.milo.opcua.sdk.server.identity.CompositeValidator;
@@ -81,6 +82,12 @@ public class TestMiloServer {
         // Required for SecurityPolicy.Aes256_Sha256_RsaPss
         Security.addProvider(new BouncyCastleProvider());
 
+        // The integration test opens many connections back-to-back (one per 
security
+        // policy, plus reconnection/subscription smoke tests). The stack's 
default
+        // connection rate limiter rejects more than 4 connection attempts per 
second,
+        // which has nothing to do with the driver under test, so disable it 
here.
+        Stack.ConnectionLimits.RATE_LIMIT_ENABLED = false;
+
         try {
             NonceUtil.blockUntilSecureRandomSeeded(10, TimeUnit.SECONDS);
         } catch (ExecutionException | InterruptedException | TimeoutException 
e) {
diff --git 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
index 62f3d89dd4..1d958f9e9a 100644
--- 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
+++ 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionBase.java
@@ -20,6 +20,7 @@ package org.apache.plc4x.java.spi.drivers;
 
 import org.apache.plc4x.java.api.EventPlcConnection;
 import org.apache.plc4x.java.api.PlcConnection;
+import org.apache.plc4x.java.api.authentication.PlcAuthentication;
 import org.apache.plc4x.java.api.exceptions.PlcConnectionException;
 import org.apache.plc4x.java.api.exceptions.PlcInvalidTagException;
 import org.apache.plc4x.java.api.exceptions.PlcRuntimeException;
@@ -75,6 +76,10 @@ public abstract class ConnectionBase<C extends 
Configuration> implements PlcConn
     private String protocolName = "unknown";
     private String transportCode = "unknown";
     private String transportName = "unknown";
+    // Authentication passed to PlcDriverManager.getConnection(url, 
authentication), set by
+    // DriverBase after construction. Null when the caller didn't supply one 
(credentials may
+    // then still come from the connection-string parameters).
+    private PlcAuthentication authentication;
 
     // Event listeners for connection state changes
     private final List<EventListener> eventListeners = new ArrayList<>();
@@ -222,6 +227,18 @@ public abstract class ConnectionBase<C extends 
Configuration> implements PlcConn
         this.transportName = transportName;
     }
 
+    void setAuthentication(PlcAuthentication authentication) {
+        this.authentication = authentication;
+    }
+
+    /**
+     * The authentication supplied to {@code getConnection(url, 
authentication)}, or {@code null}
+     * if none was given. Drivers should prefer this over connection-string 
credentials.
+     */
+    protected PlcAuthentication getAuthentication() {
+        return authentication;
+    }
+
     @Override
     public PlcConnectionMetadata getMetadata() {
         return new PlcConnectionMetadata() {
diff --git 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
index 1edb91a05e..b9d21e2598 100644
--- 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
+++ 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java
@@ -241,6 +241,9 @@ public abstract class DriverBase implements PlcDriver {
         ConnectionBase<?> connection = getConnection(configuration, 
transportInstance, auditLog);
         connection.setConnectionInfo(getProtocolCode(), getProtocolName(),
             transport.getTransportCode(), transport.getTransportName());
+        // Hand the caller-supplied authentication to the connection; 
previously it was accepted
+        // by this method but never propagated, so programmatic credentials 
were silently ignored.
+        connection.setAuthentication(plcAuthentication);
         return connection;
     }
 
diff --git 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
index 0a90870b44..f52f0e0954 100644
--- 
a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
+++ 
b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/messages/DefaultPlcWriteRequest.java
@@ -147,12 +147,15 @@ public class DefaultPlcWriteRequest implements 
PlcWriteRequest {
         }
 
         private PlcValue parsePlcValue(PlcTag tag, Object[] values) {
-            if ((values == null) || (values.length != 1)) {
-                throw new PlcRuntimeException("Expecting 1 item, but got " + 
(values != null ? values.length : "null"));
+            if ((values == null) || (values.length == 0)) {
+                throw new PlcRuntimeException("Expecting at least 1 item, but 
got " + (values != null ? values.length : "null"));
             }
-            if (values[0] instanceof PlcValue) {
+            // A single, already-built PlcValue is passed through unchanged.
+            if ((values.length == 1) && (values[0] instanceof PlcValue)) {
                 return (PlcValue) values[0];
             }
+            // One or more raw values: let the value handler build the 
PlcValue (a PlcList
+            // for arrays). Requiring exactly one value here made array writes 
impossible.
             Objects.requireNonNull(valueHandler, "valueHandler must not be 
null");
             return valueHandler.newPlcValue(tag, values);
         }
diff --git 
a/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
 
b/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
index 328bba120f..89cf479269 100644
--- 
a/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
+++ 
b/plc4j/spi/values/src/main/java/org/apache/plc4x/java/spi/values/DefaultPlcValueHandler.java
@@ -69,9 +69,16 @@ public class DefaultPlcValueHandler implements 
PlcValueHandler {
                 }
                 return ofElement(tag.getPlcValueType(), values[0]);
             }
-            //
+            // More than one value, but the tag carries no array metadata. 
Some drivers
+            // (e.g. OPC UA) don't encode array size in the tag address, so we 
can't rely on
+            // getArrayInfo() to recognise an array write. Rather than reject 
it, build a
+            // PlcList by inferring each element from the passed values.
             else {
-                throw new PlcRuntimeException("The type is not an array type, 
but more than one value was passed in as argument");
+                List<PlcValue> plcValues = new ArrayList<>(values.length);
+                for (Object value : values) {
+                    plcValues.add(ofElement(tag.getPlcValueType(), value));
+                }
+                return new PlcList(plcValues);
             }
         }
         // In all other cases, we're dealing with an array type and this needs 
to be handled separately.
@@ -120,8 +127,12 @@ public class DefaultPlcValueHandler implements 
PlcValueHandler {
     }
 
     private static PlcValue ofElement(PlcValueType type, Object value) {
-        // This is a temporary hack for drivers that don't have type 
information in their tags (ADS)
-        if (type == null) {
+        // For drivers that don't carry type information in their tags, infer 
the PlcValue
+        // from the Java object. Some drivers signal "no type" with a Java 
null (e.g. ADS),
+        // others with the PlcValueType.NULL enum (e.g. OPC UA tags without a 
type suffix);
+        // both mean the same thing, so treat them alike. Mapping the NULL 
enum onto the
+        // switch below would yield a PlcNull and silently discard the value 
being written.
+        if (type == null || type == PlcValueType.NULL) {
             return of(value);
         }
         return switch (type) {
diff --git 
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
 
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
index c5defcc7e8..c9a017fc65 100644
--- 
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
+++ 
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/BaseTransportInstance.java
@@ -29,6 +29,10 @@ public abstract class BaseTransportInstance<T extends 
TransportConfiguration> im
 
     private final T transportConfig;
     private final AuditLog auditLog;
+    // The driver-config: the URL remainder after the transport-config 
(host/port),
+    // set by the transport that parsed the address. Empty unless the transport
+    // supplies one. See TransportInstance#getDriverConfig().
+    private String driverConfig = "";
 
     public BaseTransportInstance(T transportConfig, AuditLog auditLog) {
         this.transportConfig = Objects.requireNonNull(transportConfig);
@@ -40,6 +44,20 @@ public abstract class BaseTransportInstance<T extends 
TransportConfiguration> im
         return transportConfig;
     }
 
+    @Override
+    public String getDriverConfig() {
+        return driverConfig;
+    }
+
+    /**
+     * Sets the driver-config parsed from the connection URL. Transports call 
this
+     * right after constructing the instance with the portion of the address 
they did
+     * not consume (e.g. an OPC UA {@code /milo} path).
+     */
+    public void setDriverConfig(String driverConfig) {
+        this.driverConfig = driverConfig == null ? "" : driverConfig;
+    }
+
     public AuditLog getAuditLog() {
         return auditLog;
     }
diff --git 
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
 
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
index 992190aa9c..a9505be3af 100644
--- 
a/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
+++ 
b/plc4j/transports/api/src/main/java/org/apache/plc4x/java/spi/transports/api/TransportInstance.java
@@ -31,6 +31,22 @@ public interface TransportInstance<T extends 
TransportConfiguration> {
      */
     T getConfiguration();
 
+    /**
+     * Returns the driver-config: the part of the connection URL after the
+     * transport-config (host/port) that the transport consumes and before the
+     * parameter section. Unlike the transport-config, this portion is 
meaningful
+     * only to the driver. For example, in
+     * {@code opcua:tcp://localhost:4840/milo?discovery=false} (or the 
equivalent
+     * {@code opcua:tls://...}) the transport consumes {@code localhost:4840} 
and
+     * this returns {@code /milo}. Transports whose address carries no such 
trailing
+     * part return an empty string.
+     *
+     * @return the driver-config, never {@code null}
+     */
+    default String getDriverConfig() {
+        return "";
+    }
+
     /**
      * Executes a check if the underlying transport is still open.
      *
diff --git 
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
 
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
index 874193c881..a395c1774a 100644
--- 
a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
+++ 
b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/TcpTransport.java
@@ -37,7 +37,7 @@ public class TcpTransport implements 
Transport<TcpTransportConfiguration> {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(TcpTransport.class);
 
     private static final Pattern TRANSPORT_TCP_PATTERN = Pattern.compile(
-        
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?.*");
+        
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?(?<driverConfig>.*)");
 
 
     @Override
@@ -69,6 +69,9 @@ public class TcpTransport implements 
Transport<TcpTransportConfiguration> {
         String ip = matcher.group("ip");
         String hostname = matcher.group("hostname");
         String portString = matcher.group("port");
+        // Everything after host:port (before the '?' parameter section, which 
the framework
+        // already stripped) is the driver-config, e.g. the OPC UA "/milo" 
path.
+        String driverConfig = matcher.group("driverConfig");
 
         // If the port wasn't specified, try to get a default port from the 
configuration.
         int port;
@@ -83,7 +86,9 @@ public class TcpTransport implements 
Transport<TcpTransportConfiguration> {
         InetSocketAddress remoteAddress = new InetSocketAddress((ip != null) ? 
ip : hostname, port);
 
         LOGGER.debug("Creating TCP transport instance for {}:{}", (ip != null) 
? ip : hostname, port);
-        return new TcpTransportInstance(remoteAddress, 
tcpTransportConfiguration, auditLog);
+        TcpTransportInstance instance = new 
TcpTransportInstance(remoteAddress, tcpTransportConfiguration, auditLog);
+        instance.setDriverConfig(driverConfig);
+        return instance;
     }
 
 }
diff --git 
a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
 
b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
index 2e71d9398a..aeaae8ab24 100644
--- 
a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
+++ 
b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java
@@ -42,7 +42,7 @@ public class TlsTransport implements 
Transport<TlsTransportConfiguration> {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(TlsTransport.class);
 
     private static final Pattern TRANSPORT_TLS_PATTERN = Pattern.compile(
-        
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?.*");
+        
"^((?<ip>[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3})|(?<hostname>[a-zA-Z0-9.\\-]+))(:(?<port>[0-9]{1,5}))?(?<driverConfig>.*)");
 
     @Override
     public String getTransportCode() {
@@ -73,6 +73,8 @@ public class TlsTransport implements 
Transport<TlsTransportConfiguration> {
         String ip = matcher.group("ip");
         String hostname = matcher.group("hostname");
         String portString = matcher.group("port");
+        // Everything after host:port is the driver-config (e.g. an OPC UA 
"/milo" path).
+        String driverConfig = matcher.group("driverConfig");
 
         // If the port wasn't specified, try to get a default port from the 
configuration.
         int port;
@@ -89,7 +91,9 @@ public class TlsTransport implements 
Transport<TlsTransportConfiguration> {
         LOGGER.debug("Creating TLS transport instance for {}:{} 
(verify-ssl={})",
             (ip != null) ? ip : hostname, port, 
tlsTransportConfiguration.isVerifySsl());
 
-        return new TlsTransportInstance(remoteAddress, 
tlsTransportConfiguration, auditLog);
+        TlsTransportInstance instance = new 
TlsTransportInstance(remoteAddress, tlsTransportConfiguration, auditLog);
+        instance.setDriverConfig(driverConfig);
+        return instance;
     }
 
 }


Reply via email to