Skip to content

Commit 47fd3fc

Browse files
authored
Feature/enable mtls opcua (#1246)
* Implementation using SAN * Added tests * Make applicationUri configurable
1 parent 73938fb commit 47fd3fc

File tree

13 files changed

+790
-29
lines changed

13 files changed

+790
-29
lines changed

.claude/settings.local.json

Lines changed: 0 additions & 10 deletions
This file was deleted.

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/client/OpcUaClientConfigurator.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,20 @@ public OpcUaClientConfigurator(final @NotNull String adapterId, final @NotNull P
3939

4040
@Override
4141
public void accept(final @NotNull OpcUaClientConfigBuilder configBuilder) {
42+
// Use Application URI from certificate if available, otherwise fall back to default
43+
final String applicationUri = parsedConfig.applicationUri() != null
44+
? parsedConfig.applicationUri()
45+
: Constants.OPCUA_APPLICATION_URI;
46+
47+
if (parsedConfig.applicationUri() == null) {
48+
log.info("Using default Application URI: {}", applicationUri);
49+
} else {
50+
log.info("Using Application URI from certificate: {}", applicationUri);
51+
}
52+
4253
configBuilder
4354
.setApplicationName(LocalizedText.english(Constants.OPCUA_APPLICATION_NAME))
44-
.setApplicationUri(Constants.OPCUA_APPLICATION_URI)
55+
.setApplicationUri(applicationUri)
4556
.setProductUri(Constants.OPCUA_PRODUCT_URI)
4657
.setSessionName(() -> Constants.OPCUA_SESSION_NAME_PREFIX + adapterId);
4758

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/client/ParsedConfig.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@
4646
import java.util.Set;
4747

4848
public record ParsedConfig(boolean tlsEnabled, KeystoreUtil.KeyPairWithChain keyPairWithChain,
49-
CertificateValidator clientCertificateValidator, IdentityProvider identityProvider) {
49+
CertificateValidator clientCertificateValidator, IdentityProvider identityProvider,
50+
@Nullable String applicationUri) {
5051

5152
private static final @NotNull Logger log = LoggerFactory.getLogger(ParsedConfig.class);
5253

@@ -79,7 +80,25 @@ public static Result<ParsedConfig, String> fromConfig(final OpcUaSpecificAdapter
7980
return Failure.of("Failed to create identity provider, check authentication configuration");
8081
}
8182

82-
return Success.of(new ParsedConfig(tlsEnabled, keyPairWithChain, certValidator, identityProvider.get()));
83+
// Determine Application URI with priority: configured > certificate SAN > default
84+
final String applicationUri;
85+
if (adapterConfig.getApplicationUri() != null && !adapterConfig.getApplicationUri().isBlank()) {
86+
// Priority 1: Use configured override
87+
applicationUri = adapterConfig.getApplicationUri();
88+
log.info("Using configured Application URI override: {}", applicationUri);
89+
} else if (keyPairWithChain != null && keyPairWithChain.applicationUri() != null) {
90+
// Priority 2: Use certificate SAN URI
91+
applicationUri = keyPairWithChain.applicationUri();
92+
log.info("Using Application URI from certificate: {}", applicationUri);
93+
} else {
94+
// Priority 3: Will use default in OpcUaClientConfigurator
95+
applicationUri = null;
96+
if (tlsEnabled && keyPairWithChain != null) {
97+
log.warn("Certificate does not contain Application URI in SAN extension, will use default URI");
98+
}
99+
}
100+
101+
return Success.of(new ParsedConfig(tlsEnabled, keyPairWithChain, certValidator, identityProvider.get(), applicationUri));
83102
}
84103

85104
private static @NotNull Optional<List<X509Certificate>> getTrustedCerts(@Nullable final Truststore truststore) {

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/BidirectionalOpcUaSpecificAdapterConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ public class BidirectionalOpcUaSpecificAdapterConfig extends OpcUaSpecificAdapte
2727
public BidirectionalOpcUaSpecificAdapterConfig(
2828
@JsonProperty(value = "uri", required = true) final @NotNull String uri,
2929
@JsonProperty(value = "overrideUri", defaultValue = "false") final @Nullable Boolean overrideUri,
30+
@JsonProperty("applicationUri") final @Nullable String applicationUri,
3031
@JsonProperty("auth") final @Nullable Auth auth,
3132
@JsonProperty("tls") final @Nullable Tls tls,
3233
@JsonProperty(value = "opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
3334
@JsonProperty("security") final @Nullable Security security) {
34-
super(uri, overrideUri, auth, tls, opcuaToMqttConfig, security);
35+
super(uri, overrideUri, applicationUri, auth, tls, opcuaToMqttConfig, security);
3536
}
3637
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/config/OpcUaSpecificAdapterConfig.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ public class OpcUaSpecificAdapterConfig implements ProtocolSpecificAdapterConfig
5555
defaultValue = "false")
5656
private final boolean overrideUri;
5757

58+
@JsonProperty("applicationUri")
59+
@ModuleConfigField(title = "Application URI Override",
60+
description = "Overrides the Application URI used for OPC UA client identification. If not specified, the URI from the certificate SAN extension is used, or the default URI 'urn:hivemq:edge:client' as fallback.",
61+
format = ModuleConfigField.FieldType.URI,
62+
required = false)
63+
private final @Nullable String applicationUri;
64+
5865
@JsonProperty("auth")
5966
@JsonInclude(JsonInclude.Include.NON_NULL)
6067
private final @Nullable Auth auth;
@@ -74,12 +81,14 @@ public class OpcUaSpecificAdapterConfig implements ProtocolSpecificAdapterConfig
7481
public OpcUaSpecificAdapterConfig(
7582
@JsonProperty(value = "uri", required = true) final @NotNull String uri,
7683
@JsonProperty("overrideUri") final @Nullable Boolean overrideUri,
84+
@JsonProperty("applicationUri") final @Nullable String applicationUri,
7785
@JsonProperty("auth") final @Nullable Auth auth,
7886
@JsonProperty("tls") final @Nullable Tls tls,
7987
@JsonProperty(value = "opcuaToMqtt") final @Nullable OpcUaToMqttConfig opcuaToMqttConfig,
8088
@JsonProperty("security") final @Nullable Security security) {
8189
this.uri = uri;
8290
this.overrideUri = requireNonNullElse(overrideUri, false);
91+
this.applicationUri = (applicationUri != null && !applicationUri.isBlank()) ? applicationUri : null;
8392
this.auth = auth;
8493
this.tls = requireNonNullElse(tls, new Tls(false, null, null));
8594
this.opcuaToMqttConfig =
@@ -112,6 +121,10 @@ public OpcUaSpecificAdapterConfig(
112121
return overrideUri;
113122
}
114123

124+
public @Nullable String getApplicationUri() {
125+
return applicationUri;
126+
}
127+
115128
@Override
116129
public boolean equals(final @Nullable Object o) {
117130
if (o == null || getClass() != o.getClass()) {
@@ -121,6 +134,7 @@ public boolean equals(final @Nullable Object o) {
121134
return getOverrideUri().equals(that.getOverrideUri()) &&
122135
Objects.equals(id, that.id) &&
123136
Objects.equals(getUri(), that.getUri()) &&
137+
Objects.equals(getApplicationUri(), that.getApplicationUri()) &&
124138
Objects.equals(getAuth(), that.getAuth()) &&
125139
Objects.equals(getTls(), that.getTls()) &&
126140
Objects.equals(getSecurity(), that.getSecurity()) &&
@@ -129,6 +143,6 @@ public boolean equals(final @Nullable Object o) {
129143

130144
@Override
131145
public int hashCode() {
132-
return Objects.hash(getOverrideUri(), id, getUri(), getAuth(), getTls(), getSecurity(), getOpcuaToMqttConfig());
146+
return Objects.hash(getOverrideUri(), id, getUri(), getApplicationUri(), getAuth(), getTls(), getSecurity(), getOpcuaToMqttConfig());
133147
}
134148
}

modules/hivemq-edge-module-opcua/src/main/java/com/hivemq/edge/adapters/opcua/util/KeystoreUtil.java

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
*/
1616
package com.hivemq.edge.adapters.opcua.util;
1717

18+
import org.eclipse.milo.opcua.stack.core.util.CertificateUtil;
1819
import org.jetbrains.annotations.NotNull;
20+
import org.jetbrains.annotations.Nullable;
1921

2022
import javax.net.ssl.TrustManagerFactory;
2123
import javax.net.ssl.X509TrustManager;
@@ -95,18 +97,36 @@ public class KeystoreUtil {
9597
//load keystore from TLS config
9698
final KeyStore keyStore = KeyStore.getInstance(keyStoreType);
9799
keyStore.load(fileInputStream, keyStorePassword.toCharArray());
98-
final String firstAlias = keyStore.aliases().nextElement();
99-
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(firstAlias, privateKeyPassword.toCharArray());
100-
final Certificate certificate = keyStore.getCertificate(firstAlias);
101-
final Certificate[] certificateChain = keyStore.getCertificateChain(firstAlias);
100+
101+
// Find the first key entry (not just any alias, which might be a certificate entry)
102+
String keyAlias = null;
103+
final Iterator<String> aliasIterator = keyStore.aliases().asIterator();
104+
while (aliasIterator.hasNext()) {
105+
final String alias = aliasIterator.next();
106+
if (keyStore.isKeyEntry(alias)) {
107+
keyAlias = alias;
108+
break;
109+
}
110+
}
111+
112+
if (keyAlias == null) {
113+
throw new SslException("No key entry found in KeyStore '" + keyStorePath + "'");
114+
}
115+
116+
final PrivateKey privateKey = (PrivateKey) keyStore.getKey(keyAlias, privateKeyPassword.toCharArray());
117+
final Certificate certificate = keyStore.getCertificate(keyAlias);
118+
final Certificate[] certificateChain = keyStore.getCertificateChain(keyAlias);
102119

103120
final X509Certificate certificateX509 = (X509Certificate) certificate;
104121
final X509Certificate[] certificateChainX509 = new X509Certificate[certificateChain.length];
105122
for (int i = 0; i < certificateChain.length; i++) {
106123
certificateChainX509[i] = (X509Certificate) certificateChain[i];
107124
}
108125

109-
return new KeyPairWithChain(privateKey, certificateX509, certificateChainX509);
126+
// Extract Application URI from certificate SAN extension
127+
final String applicationUri = CertificateUtil.getSanUri(certificateX509).orElse(null);
128+
129+
return new KeyPairWithChain(privateKey, certificateX509, certificateChainX509, applicationUri);
110130
} catch (final UnrecoverableKeyException e1) {
111131
throw new SslException(
112132
"Not able to recover key from KeyStore, please check your private-key-password and your keyStorePassword",
@@ -125,6 +145,7 @@ public class KeystoreUtil {
125145
}
126146

127147
public record KeyPairWithChain(@NotNull PrivateKey privateKey, @NotNull X509Certificate publicKey,
128-
@NotNull X509Certificate[] certificateChain) {
148+
@NotNull X509Certificate[] certificateChain,
149+
@Nullable String applicationUri) {
129150
}
130151
}

modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaEndpointFilterTest.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@
3030
import java.util.Optional;
3131
import java.util.stream.Collectors;
3232

33+
import static com.hivemq.edge.adapters.opcua.Constants.DEFAULT_SECURITY_POLICY;
3334
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.AES128_SHA256_RSAOAEP;
3435
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.AES256_SHA256_RSAPSS;
3536
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.BASIC128RSA15;
3637
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.BASIC256;
3738
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.BASIC256SHA256;
38-
import static com.hivemq.edge.adapters.opcua.Constants.DEFAULT_SECURITY_POLICY;
3939
import static com.hivemq.edge.adapters.opcua.config.SecPolicy.NONE;
4040
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
4141

@@ -53,6 +53,7 @@ public void whenSingleEndpointConfigSet_thenPickCorrectEndpoint() {
5353
final OpcUaSpecificAdapterConfig config = new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320",
5454
false,
5555
null,
56+
null,
5657
new Tls(true, new Keystore("path", null, null), null),
5758
null,
5859
null);
@@ -69,7 +70,7 @@ public void whenSingleEndpointConfigSet_thenPickCorrectEndpoint() {
6970
@Test
7071
public void whenSingleEndpointConfigSetAndNoKeystorePresent_thenPickNoEndpoint() {
7172
final OpcUaSpecificAdapterConfig config =
72-
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null);
73+
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null, null);
7374

7475
final String configUri = convertToUri(BASIC256SHA256);
7576
final OpcUaEndpointFilter opcUaEndpointFilter = new OpcUaEndpointFilter("id", configUri, config);
@@ -83,7 +84,7 @@ public void whenSingleEndpointConfigSetAndNoKeystorePresent_thenPickNoEndpoint()
8384
public void whenSingleEndpointConfigSetAndNotAvailOnServer_thenPickNoEndpoint() {
8485
final String configUri = convertToUri(BASIC256SHA256);
8586
final OpcUaSpecificAdapterConfig config =
86-
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null);
87+
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null, null);
8788
final OpcUaEndpointFilter opcUaEndpointFilter = new OpcUaEndpointFilter("id", configUri, config);
8889

8990
final Optional<EndpointDescription> result =
@@ -95,7 +96,7 @@ public void whenSingleEndpointConfigSetAndNotAvailOnServer_thenPickNoEndpoint()
9596
@Test
9697
public void whenDefaultEndpointConfigSet_thenPickMatchingEndpoint() {
9798
final OpcUaSpecificAdapterConfig config =
98-
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null);
99+
new OpcUaSpecificAdapterConfig("opc.tcp://127.0.0.1:49320", false, null, null, null, null, null);
99100
final OpcUaEndpointFilter opcUaEndpointFilter = new OpcUaEndpointFilter("id", convertToUri(
100101
DEFAULT_SECURITY_POLICY), config);
101102

@@ -106,7 +107,7 @@ public void whenDefaultEndpointConfigSet_thenPickMatchingEndpoint() {
106107
}
107108

108109
@NotNull
109-
private static List<EndpointDescription> convertToEndpointDescription(List<String> allUris) {
110+
private static List<EndpointDescription> convertToEndpointDescription(final @NotNull List<String> allUris) {
110111
final ArrayList<EndpointDescription> endpointList = allUris.stream()
111112
.map(policyUri -> new EndpointDescription("opc.tcp://127.0.0.1:49320",
112113
null,
@@ -124,7 +125,7 @@ private static List<EndpointDescription> convertToEndpointDescription(List<Strin
124125
private @NotNull List<String> convertToUri(final @NotNull List<SecPolicy> policies) {
125126
return policies.stream()
126127
.map(secPolicy -> secPolicy.getSecurityPolicy().getUri())
127-
.collect(Collectors.toUnmodifiableList());
128+
.toList();
128129
}
129130

130131
private @NotNull String convertToUri(final @NotNull SecPolicy policy) {

modules/hivemq-edge-module-opcua/src/test/java/com/hivemq/edge/adapters/opcua/OpcUaProtocolAdapterAuthTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ public void whenNoAuthAndNoSubscriptions_thenConnectSuccessfully() {
9797
false,
9898
null,
9999
null,
100+
null,
100101
new OpcUaToMqttConfig(1, 1000),
101102
null);
102103

@@ -122,6 +123,7 @@ public void whenBasicAuthAndNoSubscriptions_thenConnectSuccessfully() {
122123
final OpcUaSpecificAdapterConfig config = new OpcUaSpecificAdapterConfig(
123124
opcUaServerExtension.getServerUri(),
124125
false,
126+
null,
125127
auth,
126128
null,
127129
null,
@@ -148,6 +150,7 @@ public void whenTlsAndNoSubscriptions_thenConnectSuccessfully() {
148150
opcUaServerExtension.getServerUri(),
149151
false,
150152
null,
153+
null,
151154
tls,
152155
null,
153156
security);
@@ -175,6 +178,7 @@ public void whenCertAuthAndNoSubscriptions_thenConnectSuccessfully() throws Exce
175178
final OpcUaSpecificAdapterConfig config = new OpcUaSpecificAdapterConfig(
176179
opcUaServerExtension.getServerUri(),
177180
false,
181+
null,
178182
auth,
179183
tls,
180184
null,

0 commit comments

Comments
 (0)