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