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 97d0aa1234 bug(plc4j/opcua): OPC-UA hardcodes RSA-OAEP for username 
tokens (AKA support username-password authentication)
97d0aa1234 is described below

commit 97d0aa1234706418acd49c4fa6ba5c3e939d02c1
Author: Christofer Dutz <[email protected]>
AuthorDate: Fri Aug 14 15:38:02 2026 +0200

    bug(plc4j/opcua): OPC-UA hardcodes RSA-OAEP for username tokens (AKA 
support username-password authentication)
    
    Fixes: #2154
---
 .../plc4x/java/opcua/context/Conversation.java     |   4 +-
 .../java/opcua/context/EncryptionHandler.java      |  12 ++-
 .../plc4x/java/opcua/context/SecureChannel.java    |  59 ++++++++---
 .../plc4x/java/opcua/security/SecurityPolicy.java  |  44 ++++++++-
 .../plc4x/java/opcua/OpcuaPlcDriverTest.java       |  86 ++++++++++++++++
 .../SecurityPolicyPasswordEncryptionTest.java      | 110 +++++++++++++++++++++
 .../milo/examples/server/TestMiloServer.java       |  32 +++++-
 7 files changed, 326 insertions(+), 21 deletions(-)

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 492880d48d..cc8731bba6 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
@@ -606,8 +606,8 @@ public class Conversation implements SecureChannelState {
         return messageSecurity;
     }
 
-    public byte[] encryptPassword(byte[] encodeablePassword) {
-        return encryptionHandler.encryptPassword(encodeablePassword);
+    public byte[] encryptPassword(byte[] encodeablePassword, SecurityPolicy 
policy) {
+        return encryptionHandler.encryptPassword(encodeablePassword, policy);
     }
 
     public void setSecurityHeader(SecurityHeader securityHeader) {
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 80467d5359..f57d370330 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
@@ -111,9 +111,17 @@ public class EncryptionHandler {
         return new SignatureData(new 
PascalString(securityPolicy.getAsymmetricSignatureAlgorithm().getUri()), new 
PascalByteString(signed.length, signed));
     }
 
-    public byte[] encryptPassword(byte[] data) {
+    /**
+     * Encrypts a user token password with the server's public key.
+     *
+     * @param data   the encodeable password (length prefix, password, server 
nonce)
+     * @param policy the security policy of the selected user token policy - 
it decides the
+     *               algorithm, which is not necessarily the one used for the 
secure channel
+     * @return the encrypted password, or null if it could not be encrypted
+     */
+    public byte[] encryptPassword(byte[] data, SecurityPolicy policy) {
         try {
-            Cipher cipher = 
Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding");
+            Cipher cipher = 
policy.getAsymmetricEncryptionAlgorithm().getCipher();
             cipher.init(Cipher.ENCRYPT_MODE, 
this.conversation.getRemoteCertificate().getPublicKey());
             return cipher.doFinal(data);
         } catch (Exception e) {
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 37ba25af84..2f9cba5609 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
@@ -49,11 +49,12 @@ import 
org.apache.plc4x.java.opcua.security.SecurityPolicy.SignatureAlgorithm;
 import org.apache.plc4x.java.spi.buffers.api.exceptions.BufferException;
 import org.apache.plc4x.java.spi.buffers.bytebased.ReadBufferByteBased;
 import org.apache.plc4x.java.opcua.protocol.chunk.PayloadConverter;
-import org.apache.plc4x.java.spi.buffers.bytebased.WriteBufferByteBased;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
@@ -62,7 +63,6 @@ import java.util.regex.Pattern;
 public class SecureChannel {
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SecureChannel.class);
-    private static final String PASSWORD_ENCRYPTION_ALGORITHM = 
"http://www.w3.org/2001/04/xmlenc#rsa-oaep";;
     public static final PascalString NULL_STRING = new PascalString("");
     public static final PascalByteString NULL_BYTE_STRING = new 
PascalByteString(-1, null);
     public static final Pattern INET_ADDRESS_PATTERN = 
Pattern.compile("(.(?<transportCode>tcp|https?))?://" +
@@ -314,7 +314,8 @@ public class SecureChannel {
 
         PascalString policyId = selectedEndpoint.getValue().getPolicyId();
         UserTokenType tokenType = selectedEndpoint.getValue().getTokenType();
-        ExtensionObject userIdentityToken = getIdentityToken(tokenType, 
policyId.getStringValue());
+        SecurityPolicy tokenSecurityPolicy = 
userTokenSecurityPolicy(selectedEndpoint);
+        ExtensionObject userIdentityToken = getIdentityToken(tokenType, 
policyId.getStringValue(), tokenSecurityPolicy);
         RequestHeader requestHeader = conversation.createRequestHeader();
         SignatureData clientSignature = new SignatureData(NULL_STRING, 
NULL_BYTE_STRING);
         if (conversation.getSecurityPolicy() != SecurityPolicy.NONE) {
@@ -437,8 +438,7 @@ public class SecureChannel {
             ReadBufferByteBased readBuffer = 
toBuffer(opcuaOpenResponse::getMessage);
             ExtensionObject message = ExtensionObject.staticParse(readBuffer, 
false);
 
-            if (message.getBody() instanceof ServiceFault) {
-                ServiceFault fault = (ServiceFault) message.getBody();
+            if (message.getBody() instanceof ServiceFault fault) {
                 throw new 
PlcRuntimeException(Conversation.toProtocolException(fault));
             }
 
@@ -519,7 +519,7 @@ public class SecureChannel {
         }
 
         serverEndpoints.sort(Comparator.comparing(e -> 
e.getKey().getSecurityLevel()));
-        return serverEndpoints.get(0);
+        return serverEndpoints.getFirst();
     }
 
     private boolean isMatchingEndpointDescription(EndpointDescription 
endpointDescription) {
@@ -563,14 +563,42 @@ public class SecureChannel {
         return policy.getTokenType() == UserTokenType.userTokenTypeUserName && 
username != null;
     }
 
+    /**
+     * The security policy governing the user identity token. A 
UserTokenPolicy may name its own
+     * securityPolicyUri; when it doesn't, the endpoint's own policy applies.
+     */
+    private SecurityPolicy userTokenSecurityPolicy(Entry<EndpointDescription, 
UserTokenPolicy> selectedEndpoint) {
+        PascalString tokenPolicyUri = 
selectedEndpoint.getValue().getSecurityPolicyUri();
+        if (tokenPolicyUri != null && tokenPolicyUri.getStringValue() != null
+            && !tokenPolicyUri.getStringValue().isEmpty()) {
+            Optional<SecurityPolicy> policy = 
SecurityPolicy.findByUri(tokenPolicyUri.getStringValue());
+            if (policy.isPresent()) {
+                LOGGER.debug("User token policy declares security policy {}", 
policy.get());
+                return policy.get();
+            }
+            LOGGER.warn("Unknown user token security policy '{}', falling back 
to the endpoint policy",
+                tokenPolicyUri.getStringValue());
+        }
+        return 
SecurityPolicy.findByUri(selectedEndpoint.getKey().getSecurityPolicyUri().getStringValue())
+            .orElseGet(conversation::getSecurityPolicy);
+    }
+
     /**
      * Creates an IdentityToken to authenticate with a server.
      *
      * @param tokenType      the token type
      * @param securityPolicy the security policy
      * @return returns an ExtensionObject with an IdentityToken.
+     * <p>
+     * Builds the user identity token for the selected user token policy.
+     * <p>
+     * The password encryption is dictated by the security policy of the 
<em>user token policy</em>,
+     * which is not necessarily the one securing the channel: a token policy 
may name its own, and
+     * only when it doesn't, does the endpoint's policy apply (OPC UA Part 4, 
UserNameIdentityToken).
+     * A policy of None means the password travels in plain text - see GH-2154.
      */
-    private ExtensionObject getIdentityToken(UserTokenType tokenType, String 
securityPolicy) {
+    private ExtensionObject getIdentityToken(UserTokenType tokenType, String 
securityPolicy,
+                                             SecurityPolicy 
tokenSecurityPolicy) {
         ExpandedNodeId extExpandedNodeId;
         switch (tokenType) {
             case userTokenTypeAnonymous:
@@ -589,7 +617,7 @@ public class SecureChannel {
             case userTokenTypeUserName:
                 //Encrypt the password using the server nonce and server 
public key
                 byte[] remoteNonce = conversation.getRemoteNonce();
-                byte[] passwordBytes = this.password == null ? new byte[0] : 
this.password.getBytes();
+                byte[] passwordBytes = this.password == null ? new byte[0] : 
this.password.getBytes(StandardCharsets.UTF_8);
                 ByteBuffer encodeableBuffer = ByteBuffer.allocate(4 + 
passwordBytes.length + remoteNonce.length);
                 encodeableBuffer.order(java.nio.ByteOrder.LITTLE_ENDIAN);
                 encodeableBuffer.putInt(passwordBytes.length + 
remoteNonce.length);
@@ -599,12 +627,21 @@ public class SecureChannel {
                 encodeableBuffer.position(0);
                 encodeableBuffer.get(encodeablePassword);
 
-                byte[] encryptedPassword = 
conversation.encryptPassword(encodeablePassword);
+                byte[] tokenPassword;
+                String encryptionAlgorithm;
+                if (tokenSecurityPolicy == SecurityPolicy.NONE) {
+                    // No encryption: the password is sent as-is and no 
algorithm is declared.
+                    tokenPassword = encodeablePassword;
+                    encryptionAlgorithm = "";
+                } else {
+                    tokenPassword = 
conversation.encryptPassword(encodeablePassword, tokenSecurityPolicy);
+                    encryptionAlgorithm = 
tokenSecurityPolicy.getAsymmetricEncryptionAlgorithm().getUri();
+                }
                 UserNameIdentityToken userNameIdentityToken = new 
UserNameIdentityToken(
                     new PascalString(securityPolicy),
                     new PascalString(this.username),
-                    new PascalByteString(encryptedPassword.length, 
encryptedPassword),
-                    new PascalString(PASSWORD_ENCRYPTION_ALGORITHM)
+                    new PascalByteString(tokenPassword.length, tokenPassword),
+                    new PascalString(encryptionAlgorithm)
                 );
 
                 extExpandedNodeId = new ExpandedNodeId(false,           
//Namespace Uri Specified
diff --git 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/SecurityPolicy.java
 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/SecurityPolicy.java
index 73b868b831..1568a59ff1 100644
--- 
a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/SecurityPolicy.java
+++ 
b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/SecurityPolicy.java
@@ -24,6 +24,7 @@ import javax.crypto.NoSuchPaddingException;
 import java.security.NoSuchAlgorithmException;
 import java.security.Signature;
 import java.util.Arrays;
+import java.util.Optional;
 
 public enum SecurityPolicy {
     NONE("http://opcfoundation.org/UA/SecurityPolicy#None";,
@@ -38,7 +39,7 @@ public enum SecurityPolicy {
         new MacSignatureAlgorithm("HmacSHA1"),
         new EncryptionAlgorithm("AES/CBC/NoPadding"),
         new SignatureAlgorithm("SHA1withRSA", 
"http://www.w3.org/2000/09/xmldsig#rsa-sha1";),
-        new EncryptionAlgorithm("RSA/ECB/PKCS1Padding"),
+        new EncryptionAlgorithm("RSA/ECB/PKCS1Padding", 
"http://www.w3.org/2001/04/xmlenc#rsa-1_5";),
         20, 16, 16, 16, 16
     ),
 
@@ -46,7 +47,7 @@ public enum SecurityPolicy {
         new MacSignatureAlgorithm("HmacSHA1"),
         new EncryptionAlgorithm("AES/CBC/NoPadding"),
         new SignatureAlgorithm("SHA1withRSA", 
"http://www.w3.org/2000/09/xmldsig#rsa-sha1";),
-        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"),
+        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding", 
"http://www.w3.org/2001/04/xmlenc#rsa-oaep";),
         20, 24, 32, 16, 32
     ),
 
@@ -54,7 +55,7 @@ public enum SecurityPolicy {
         new MacSignatureAlgorithm("HmacSHA256"),
         new EncryptionAlgorithm("AES/CBC/NoPadding"),
         new SignatureAlgorithm("SHA256withRSA", 
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";),
-        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"),
+        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding", 
"http://www.w3.org/2001/04/xmlenc#rsa-oaep";),
         32, 32, 32, 16, 32
     ),
 
@@ -62,7 +63,7 @@ public enum SecurityPolicy {
         new MacSignatureAlgorithm("HmacSHA256"),
         new EncryptionAlgorithm("AES/CBC/NoPadding"),
         new SignatureAlgorithm("SHA256withRSA", 
"http://www.w3.org/2001/04/xmldsig-more#rsa-sha256";),
-        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding"),
+        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA-1AndMGF1Padding", 
"http://www.w3.org/2001/04/xmlenc#rsa-oaep";),
         32, 32, 16, 16, 32
     ),
 
@@ -70,7 +71,7 @@ public enum SecurityPolicy {
         new MacSignatureAlgorithm("HmacSHA256"),
         new EncryptionAlgorithm("AES/CBC/NoPadding"),
         new SignatureAlgorithm("SHA256withRSA/PSS", 
"http://opcfoundation.org/UA/security/rsa-pss-sha2-256";),
-        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA256AndMGF1Padding"),
+        new EncryptionAlgorithm("RSA/ECB/OAEPWithSHA256AndMGF1Padding", 
"http://opcfoundation.org/UA/security#rsa-oaep-sha2-256";),
         32, 32, 32, 16, 32
     );
 
@@ -109,6 +110,20 @@ public enum SecurityPolicy {
         this.nonceLength = nonceLength;
     }
 
+    /**
+     * Looks a policy up by its {@code 
http://opcfoundation.org/UA/SecurityPolicy#...} URI, which is
+     * how policies are named on the wire. Empty when the URI is unknown - 
callers decide what to
+     * fall back to, rather than getting an exception.
+     */
+    public static Optional<SecurityPolicy> findByUri(String securityPolicyUri) 
{
+        if (securityPolicyUri == null || securityPolicyUri.isEmpty()) {
+            return Optional.empty();
+        }
+        return Arrays.stream(values())
+            .filter(v -> 
v.getSecurityPolicyUri().equalsIgnoreCase(securityPolicyUri))
+            .findAny();
+    }
+
     public static SecurityPolicy findByName(String securityPolicy) {
         return Arrays.stream(values())
             .filter(v -> v.name().equalsIgnoreCase(securityPolicy))
@@ -196,14 +211,33 @@ public enum SecurityPolicy {
 
     public static class EncryptionAlgorithm {
         private final String name;
+        private final String uri;
 
         EncryptionAlgorithm(String name) {
+            this(name, "");
+        }
+
+        EncryptionAlgorithm(String name, String uri) {
             this.name = name;
+            this.uri = uri;
         }
 
         public Cipher getCipher() throws NoSuchPaddingException, 
NoSuchAlgorithmException {
             return Cipher.getInstance(name);
         }
+
+        public String getName() {
+            return name;
+        }
+
+        /**
+         * The algorithm identifier as it has to be declared on the wire, e.g. 
in the
+         * {@code encryptionAlgorithm} field of a UserNameIdentityToken. Empty 
when the algorithm
+         * is never named on the wire (the symmetric ones) or when there is no 
encryption at all.
+         */
+        public String getUri() {
+            return uri;
+        }
     }
 
 
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
index 335d4f516c..344880c841 100644
--- 
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
@@ -34,6 +34,7 @@ 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.exceptions.PlcConnectionException;
 import org.apache.plc4x.java.api.PlcConnectionManager;
 import org.apache.plc4x.java.api.PlcDriverManager;
 import org.apache.plc4x.java.api.authentication.PlcNullAuthentication;
@@ -951,6 +952,91 @@ public class OpcuaPlcDriverTest {
         }
     }
 
+    /**
+     * Username/password authentication against the Milo test server, which 
accepts
+     * {@code user}/{@code password1} and {@code admin}/{@code password2}.
+     * <p>
+     * The password is encrypted with the algorithm named by the <em>user 
token policy</em> the
+     * server advertises, which is not necessarily the one securing the 
channel. The test server
+     * exercises both variants on purpose: its unsecured endpoint advertises 
the username policy
+     * with Basic128Rsa15 (RSA-PKCS#1 v1.5) while the secured endpoints use 
Milo's default
+     * Basic256 (RSA-OAEP). PLC4X used to hardcode RSA-OAEP, so the PKCS#1 
case was rejected with
+     * BadIdentityTokenInvalid - see GH-2154.
+     */
+    @Nested
+    class Authentication {
+
+        @Test
+        public void usernamePasswordOverUnsecuredEndpoint() throws Exception {
+            // The unsecured endpoint's username token policy demands 
RSA-PKCS#1 v1.5.
+            try (PlcConnection connection = new DefaultPlcDriverManager()
+                .getConnection(credentials(tcpConnectionAddress, "user", 
"password1"))) {
+                assertThat(connection.isConnected()).isTrue();
+                assertReadWorks(connection);
+            }
+        }
+
+        @Test
+        public void secondUserAccountAlsoAuthenticates() throws Exception {
+            try (PlcConnection connection = new DefaultPlcDriverManager()
+                .getConnection(credentials(tcpConnectionAddress, "admin", 
"password2"))) {
+                assertThat(connection.isConnected()).isTrue();
+                assertReadWorks(connection);
+            }
+        }
+
+        @ParameterizedTest
+        
@MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getConnectionSecurityPolicies")
+        public void usernamePasswordWithSecurityPolicy(SecurityPolicy policy, 
MessageSecurity messageSecurity) throws Exception {
+            String connectionString = credentials(getConnectionString(policy, 
messageSecurity), "user", "password1");
+
+            try (PlcConnection connection = new 
DefaultPlcDriverManager().getConnection(connectionString)) {
+                assertThat(connection.isConnected())
+                    .describedAs("authenticated connection with %s/%s", 
policy, messageSecurity)
+                    .isTrue();
+                assertReadWorks(connection);
+            }
+        }
+
+        @Test
+        public void wrongPasswordIsRejected() {
+            assertThatThrownBy(() -> new DefaultPlcDriverManager()
+                .getConnection(credentials(tcpConnectionAddress, "user", 
"not-the-password")))
+                .isInstanceOf(PlcConnectionException.class);
+        }
+
+        @Test
+        public void unknownUserIsRejected() {
+            assertThatThrownBy(() -> new DefaultPlcDriverManager()
+                .getConnection(credentials(tcpConnectionAddress, "nobody", 
"password1")))
+                .isInstanceOf(PlcConnectionException.class);
+        }
+
+        /**
+         * Without credentials the driver has to fall back to the anonymous 
token policy.
+         */
+        @Test
+        public void noCredentialsConnectsAnonymously() throws Exception {
+            try (PlcConnection connection = new 
DefaultPlcDriverManager().getConnection(tcpConnectionAddress)) {
+                assertThat(connection.isConnected()).isTrue();
+                assertReadWorks(connection);
+            }
+        }
+
+        private String credentials(String connectionString, String username, 
String password) {
+            return connectionString + PARAM_DIVIDER + params(
+                entry("username", username),
+                entry("password", password));
+        }
+
+        private void assertReadWorks(PlcConnection connection) throws 
Exception {
+            PlcReadResponse response = connection.readRequestBuilder()
+                .addTagAddress("value", BOOL_IDENTIFIER_READ_WRITE)
+                .build().execute().get(30, TimeUnit.SECONDS);
+            
assertThat(response.getResponseCode("value")).isEqualTo(PlcResponseCode.OK);
+        }
+    }
+
     @Nested
     class readWrite {
         // Addresses no longer need a ";TYPE" suffix: the driver resolves each 
node's data type from
diff --git 
a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/SecurityPolicyPasswordEncryptionTest.java
 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/SecurityPolicyPasswordEncryptionTest.java
new file mode 100644
index 0000000000..46bf5f6fea
--- /dev/null
+++ 
b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/security/SecurityPolicyPasswordEncryptionTest.java
@@ -0,0 +1,110 @@
+/*
+ * 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 org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.Cipher;
+import java.security.Security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The password of a UserNameIdentityToken has to be encrypted with the 
algorithm of the selected
+ * user token policy, and the token has to declare exactly that algorithm - 
see GH-2154. The driver
+ * used to hardcode RSA-OAEP and the matching URI, so servers advertising 
Basic128Rsa15 (which
+ * requires RSA-PKCS#1 v1.5) rejected the token with BadIdentityTokenInvalid.
+ */
+class SecurityPolicyPasswordEncryptionTest {
+
+    @BeforeAll
+    static void registerBouncyCastle() {
+        // The driver registers BouncyCastle when it builds an 
EncryptionHandler; some of the
+        // transformation names below (notably BC's 
OAEPWithSHA256AndMGF1Padding, which is *not*
+        // the same as SunJCE's OAEPWithSHA-256AndMGF1Padding) only resolve 
with it present.
+        Security.addProvider(new BouncyCastleProvider());
+    }
+
+    @Test
+    void basic128Rsa15UsesPkcs1() throws Exception {
+        SecurityPolicy policy = SecurityPolicy.findByUri(
+            
"http://opcfoundation.org/UA/SecurityPolicy#Basic128Rsa15";).orElse(null);
+
+        assertNotNull(policy);
+        assertEquals("http://www.w3.org/2001/04/xmlenc#rsa-1_5";,
+            policy.getAsymmetricEncryptionAlgorithm().getUri());
+        assertEquals("RSA/ECB/PKCS1Padding", 
policy.getAsymmetricEncryptionAlgorithm().getName());
+    }
+
+    @Test
+    void sha1BasedPoliciesUseRsaOaep() throws Exception {
+        for (String uri : new String[]{
+            "http://opcfoundation.org/UA/SecurityPolicy#Basic256";,
+            "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";,
+            
"http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep"}) {
+            SecurityPolicy policy = SecurityPolicy.findByUri(uri).orElse(null);
+            assertNotNull(policy, uri);
+            assertEquals("http://www.w3.org/2001/04/xmlenc#rsa-oaep";,
+                policy.getAsymmetricEncryptionAlgorithm().getUri(), uri);
+        }
+    }
+
+    @Test
+    void rsaPssPolicyUsesOaepSha256() {
+        SecurityPolicy policy = SecurityPolicy.findByUri(
+            
"http://opcfoundation.org/UA/SecurityPolicy#Aes256_Sha256_RsaPss";).orElse(null);
+
+        assertNotNull(policy);
+        assertEquals("http://opcfoundation.org/UA/security#rsa-oaep-sha2-256";,
+            policy.getAsymmetricEncryptionAlgorithm().getUri());
+    }
+
+    /**
+     * A policy of None means the password is not encrypted at all, so it 
declares no algorithm.
+     */
+    @Test
+    void noneDeclaresNoAlgorithm() {
+        SecurityPolicy policy = SecurityPolicy.findByUri(
+            "http://opcfoundation.org/UA/SecurityPolicy#None";).orElse(null);
+
+        assertNotNull(policy);
+        
assertTrue(policy.getAsymmetricEncryptionAlgorithm().getUri().isEmpty());
+    }
+
+    /**
+     * Every declared algorithm has to be one the JVM can actually instantiate 
- a typo in the
+     * transformation name would only show up at connect time otherwise.
+     */
+    @Test
+    void everyEncryptingPolicyResolvesToAUsableCipher() throws Exception {
+        for (SecurityPolicy policy : SecurityPolicy.values()) {
+            if (policy == SecurityPolicy.NONE) {
+                continue;
+            }
+            Cipher cipher = 
policy.getAsymmetricEncryptionAlgorithm().getCipher();
+            assertNotNull(cipher, policy.name());
+            
assertTrue(policy.getAsymmetricEncryptionAlgorithm().getUri().startsWith("http"),
+                policy + " must declare an algorithm URI");
+        }
+    }
+}
diff --git 
a/plc4j/drivers/opcua/src/test/resources/opcua/server/src/org/eclipse/milo/examples/server/TestMiloServer.java
 
b/plc4j/drivers/opcua/src/test/resources/opcua/server/src/org/eclipse/milo/examples/server/TestMiloServer.java
index 840032701e..043573d7a0 100644
--- 
a/plc4j/drivers/opcua/src/test/resources/opcua/server/src/org/eclipse/milo/examples/server/TestMiloServer.java
+++ 
b/plc4j/drivers/opcua/src/test/resources/opcua/server/src/org/eclipse/milo/examples/server/TestMiloServer.java
@@ -56,7 +56,9 @@ import 
org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
 import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime;
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText;
 import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.UserTokenType;
 import org.eclipse.milo.opcua.stack.core.types.structured.BuildInfo;
+import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
 import org.eclipse.milo.opcua.stack.core.util.CertificateUtil;
 import org.eclipse.milo.opcua.stack.core.util.NonceUtil;
 import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator;
@@ -208,6 +210,19 @@ public class TestMiloServer {
         plc4xTestNamespace.startup();
     }
 
+    /**
+     * A UserName token policy that names Basic128Rsa15 as its security 
policy, so the password has
+     * to be encrypted with RSA-PKCS#1 v1.5. Milo's built-in 
USER_TOKEN_POLICY_USERNAME uses
+     * Basic256 (RSA-OAEP); having both lets the test suite cover either 
algorithm.
+     */
+    private static final UserTokenPolicy USER_TOKEN_POLICY_USERNAME_RSA15 = 
new UserTokenPolicy(
+        "username_rsa15",
+        UserTokenType.UserName,
+        null,
+        null,
+        SecurityPolicy.Basic128Rsa15.getUri()
+    );
+
     private Set<EndpointConfiguration> 
createEndpointConfigurations(X509Certificate certificate) {
         Set<EndpointConfiguration> endpointConfigurations = new 
LinkedHashSet<>();
 
@@ -232,7 +247,22 @@ public class TestMiloServer {
                         USER_TOKEN_POLICY_X509
                     );
 
-                EndpointConfiguration.Builder noSecurityBuilder = 
builder.copy()
+                // The unsecured endpoint deliberately advertises its username 
token policy with
+                // Basic128Rsa15 instead of Milo's default Basic256. That 
policy requires the
+                // password to be encrypted with RSA-PKCS#1 v1.5 rather than 
RSA-OAEP, which is the
+                // case PLC4X used to get wrong (it hardcoded OAEP and servers 
answered
+                // BadIdentityTokenInvalid - see GH-2154). The secured 
endpoints below keep the
+                // default username policy, so both password-encryption paths 
stay covered.
+                EndpointConfiguration.Builder noSecurityBuilder = 
EndpointConfiguration.newBuilder()
+                    .setBindAddress(bindAddress)
+                    .setHostname(hostname)
+                    .setPath("/milo")
+                    .setCertificate(certificate)
+                    .addTokenPolicies(
+                        USER_TOKEN_POLICY_ANONYMOUS,
+                        USER_TOKEN_POLICY_USERNAME_RSA15,
+                        USER_TOKEN_POLICY_X509
+                    )
                     .setSecurityPolicy(SecurityPolicy.None)
                     .setSecurityMode(MessageSecurityMode.None);
                 
endpointConfigurations.add(buildTcpEndpoint(noSecurityBuilder));

Reply via email to