diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/EndpointSelectionIndex.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/EndpointSelectionIndex.java new file mode 100644 index 0000000000..476e648514 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/EndpointSelectionIndex.java @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server; + +import static java.util.Objects.requireNonNullElse; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy; +import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An immutable index from {@link EndpointSelectionKey} to the effective endpoint it identifies. + * + *

Built once per resolved endpoint set, this index is the single definition of endpoint + * selection shared by configuration validation and runtime channel/session resolution: + * + *

+ */ +@NullMarked +final class EndpointSelectionIndex { + + private final Map> groups; + private final Set collidingKeys; + private final List collisions; + + private EndpointSelectionIndex( + Map> groups, + Set collidingKeys, + List collisions) { + + this.groups = groups; + this.collidingKeys = collidingKeys; + this.collisions = collisions; + } + + /** + * Build an index over {@code endpoints}, recording any selection key collisions. + * + *

Endpoints sharing a key are a collision unless they are {@link + * EndpointSelectionKey#isSessionEquivalent(EndpointDescription, EndpointDescription) + * Session-equivalent}, i.e. host/port substitution aliases of the same effective endpoint. + * + * @param endpoints the resolved endpoints to index. + * @return the index; call {@link #validate()} to fail on recorded collisions. + */ + static EndpointSelectionIndex build(List endpoints) { + var groups = new LinkedHashMap>(); + + for (ResolvedEndpoint endpoint : endpoints) { + EndpointSelectionKey key = EndpointSelectionKey.of(endpoint.endpointDescription()); + groups.computeIfAbsent(key, k -> new ArrayList<>()).add(endpoint); + } + + var collidingKeys = new HashSet(); + var collisions = new ArrayList(); + + groups.forEach( + (key, group) -> { + EndpointDescription first = group.get(0).endpointDescription(); + + boolean equivalent = + group.stream() + .allMatch( + r -> + EndpointSelectionKey.isSessionEquivalent(first, r.endpointDescription())); + + if (!equivalent) { + collidingKeys.add(key); + + String colliding = + group.stream() + .map(r -> describe(r.endpointDescription())) + .collect(Collectors.joining(", ")); + + collisions.add( + String.format( + "endpoints indistinguishable at OpenSecureChannel differ in" + + " Session-sensitive properties: selectionKey=%s, endpoints=[%s]", + key, colliding)); + } + }); + + return new EndpointSelectionIndex(groups, collidingKeys, collisions); + } + + /** + * Fail if any selection key is claimed by non-equivalent endpoints. + * + *

Endpoints intended to differ only in supported authentication methods must instead combine + * their {@link UserTokenPolicy}s into a single endpoint configuration; endpoints intended to have + * distinct Session policy or access behavior must be distinguishable by a wire-observable + * selector (SecurityPolicy, MessageSecurityMode, or endpoint certificate). + * + * @throws UaException with {@link StatusCodes#Bad_ConfigurationError} identifying each colliding + * selection key and its endpoints. + */ + void validate() throws UaException { + if (!collisions.isEmpty()) { + throw new UaException( + StatusCodes.Bad_ConfigurationError, + "ambiguous endpoint configuration: " + String.join("; ", collisions)); + } + } + + /** + * Resolve {@code key} to the unique effective endpoint it identifies. + * + *

When multiple host/port substitution aliases share the key, the alias whose URL matches + * {@code requestedEndpointUrl} is preferred (host and port, then host only); the first alias is + * used otherwise. Aliases carry identical Session-sensitive state, so the choice affects only the + * advertised URL. + * + * @param key the {@link EndpointSelectionKey} to resolve. + * @param requestedEndpointUrl the endpoint URL requested by the client, if available. + * @return the unique {@link ResolvedEndpoint} for {@code key}, or empty if there is none or the + * key is among the recorded collisions. + */ + Optional select( + EndpointSelectionKey key, @Nullable String requestedEndpointUrl) { + + List group = groups.get(key); + + if (group == null || collidingKeys.contains(key)) { + return Optional.empty(); + } + + return Optional.of( + EndpointSelectionKey.preferRequestedUrl( + group, requestedEndpointUrl, r -> r.endpointDescription().getEndpointUrl())); + } + + private static String describe(EndpointDescription endpoint) { + String tokenPolicies = + Stream.of(requireNonNullElse(endpoint.getUserIdentityTokens(), new UserTokenPolicy[0])) + .map(p -> p.getTokenType() + "/" + p.getPolicyId()) + .collect(Collectors.joining(",", "[", "]")); + + return endpoint.getEndpointUrl() + " userTokenPolicies=" + tokenPolicies; + } +} diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java index af571ac08f..062e353616 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServer.java @@ -116,6 +116,7 @@ import org.eclipse.milo.opcua.stack.core.util.LongSequence; import org.eclipse.milo.opcua.stack.core.util.ManifestUtil; import org.eclipse.milo.opcua.stack.core.util.NonceUtil; +import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey; import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport; import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory; import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext; @@ -170,6 +171,7 @@ public class OpcUaServer extends AbstractServiceHandler { private final ServerDiagnosticsSummary diagnosticsSummary = new ServerDiagnosticsSummary(this); private final Lazy> resolvedEndpoints = new Lazy<>(); + private final Lazy endpointSelectionIndex = new Lazy<>(); private final List boundEndpoints = new CopyOnWriteArrayList<>(); private final CertificateIdentitySelector endpointCertificateIdentitySelector = @@ -357,6 +359,15 @@ public ServerTable getServerTable() { } public CompletableFuture startup() { + try { + // Reject ambiguous endpoint configurations before binding anything: two Session-capable + // endpoints mapping to the same wire-observable selection key cannot be told apart at + // OpenSecureChannel time, so runtime selection would depend on collection ordering. + getEndpointSelectionIndex().validate(); + } catch (UaException e) { + return CompletableFuture.failedFuture(e); + } + eventFactory.startup(); eventInstantiator.startup(); @@ -928,9 +939,28 @@ private OpcServerTransport getOrCreateTransport(TransportProfile transportProfil * advertised without a listening socket behind it. Conversely, an endpoint bound at startup * remains bound even if it no longer resolves. Re-binding after a reset is not currently * supported; a server restart is required to change the set of bound sockets. + * + *

When the endpoint set is next resolved, it is validated for selection-key collisions the + * same way {@link #startup()} validates the initial configuration. A collision cannot fail an + * already-running server, so it is logged as an error instead; the colliding endpoints are not + * selectable until the configuration is corrected and the cache reset again. */ public void resetEndpointDescriptionCache() { resolvedEndpoints.reset(); + endpointSelectionIndex.reset(); + } + + private EndpointSelectionIndex getEndpointSelectionIndex() { + return endpointSelectionIndex.get( + () -> { + var index = EndpointSelectionIndex.build(getResolvedEndpoints()); + try { + index.validate(); + } catch (UaException e) { + logger.error("Colliding endpoints will not be selectable: {}", e.getMessage()); + } + return index; + }); } private List getResolvedEndpoints() { @@ -1167,6 +1197,15 @@ public List getEndpointDescriptions() { return getResolvedEndpoints().stream().map(ResolvedEndpoint::endpointDescription).toList(); } + @Override + public Optional selectEndpoint( + EndpointSelectionKey key, @Nullable String requestedEndpointUrl) { + + return getEndpointSelectionIndex() + .select(key, requestedEndpointUrl) + .map(ResolvedEndpoint::endpointDescription); + } + @Override public EncodingContext getEncodingContext() { return staticEncodingContext; @@ -1211,6 +1250,11 @@ private void handleServiceRequest( String path = EndpointUtil.getPath(context.getEndpointUrl()); if (context.getSecureChannel().getSecurityPolicy() == SecurityPolicy.None) { + // An unsecured channel is discovery-only unless an explicit SecurityPolicy.None endpoint + // currently exists for this transport and path. The current endpoint descriptions are + // re-checked on every request, rather than trusting a selection captured at + // OpenSecureChannel time, so that removing the None endpoint and resetting the endpoint + // description cache locks down already-open unsecured channels. if (getEndpointDescriptions().stream() .filter(e -> EndpointUtil.getPath(e.getEndpointUrl()).equals(path)) .filter( diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java index 1aad0bce1f..cd7ff2e80d 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigBuilder.java @@ -13,6 +13,7 @@ import static java.util.Objects.requireNonNull; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -33,7 +34,7 @@ public class OpcUaServerConfigBuilder { - private Set endpoints = new HashSet<>(); + private Set endpoints = new LinkedHashSet<>(); private Set reverseConnectTargets = new HashSet<>(); private LocalizedText applicationName = @@ -63,8 +64,18 @@ public class OpcUaServerConfigBuilder { private ExecutorService executor; private ScheduledExecutorService scheduledExecutor; + /** + * Set the endpoints the server offers. + * + *

The builder copies the supplied set, preserving its iteration order, so later changes to + * {@code endpointConfigs} do not affect the built configuration. + * + * @param endpointConfigs the complete set of endpoints the server offers. + * @return this builder. + */ public OpcUaServerConfigBuilder setEndpoints(Set endpointConfigs) { - this.endpoints = endpointConfigs; + Objects.requireNonNull(endpointConfigs, "endpointConfigs"); + this.endpoints = new LinkedHashSet<>(endpointConfigs); return this; } diff --git a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java index 621d3cf983..0936afe3c9 100644 --- a/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java +++ b/opc-ua-sdk/sdk-server/src/main/java/org/eclipse/milo/opcua/sdk/server/SessionManager.java @@ -73,6 +73,7 @@ import org.eclipse.milo.opcua.stack.core.util.EndpointUtil; import org.eclipse.milo.opcua.stack.core.util.NonceUtil; import org.eclipse.milo.opcua.stack.core.util.TaskQueue; +import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey; import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -921,9 +922,11 @@ public ActivateSessionResponse activateSession( ByteString clientCertificateBytes = context.getSecureChannel().getRemoteCertificateBytes(); + // The identity token is presented against the endpoint selected by the replacement + // channel, so decode it with that endpoint's token policies, not the previous + // endpoint's. UserIdentityToken identityToken = - decodeIdentityToken( - request.getUserIdentityToken(), session.getEndpoint().getUserIdentityTokens()); + decodeIdentityToken(request.getUserIdentityToken(), endpoint.getUserIdentityTokens()); /* * The user-token signature for enhanced (ECC/RSA-DH) policies is bound to the SecureChannel @@ -1034,32 +1037,49 @@ public ActivateSessionResponse activateSession( } } + /** + * Get the {@link EndpointDescription} the Session carried by {@code context} must be associated + * with. + * + *

The authoritative source is the endpoint the transport selected for the SecureChannel during + * OpenSecureChannel; a Session is always bound to that endpoint, never to an independent + * re-derivation. A context without a propagated selection (a discovery-only unsecured channel) + * falls back to the same selection-key lookup the transport uses, which yields exactly one + * endpoint or none -- never an arbitrary pick among multiple candidates. + * + * @param context the {@link ServiceRequestContext} carrying the channel's endpoint selection. + * @return the {@link EndpointDescription} to associate the Session with. + * @throws UaException with {@link StatusCodes#Bad_SecurityChecksFailed} if no unique endpoint is + * identified, including CreateSession/ActivateSession on a discovery-only unsecured channel. + */ private EndpointDescription findSessionEndpoint(ServiceRequestContext context) throws UaException { - return server.getApplicationContext().getEndpointDescriptions().stream() - .filter( - e -> { - boolean transportMatch = - java.util.Objects.equals( - e.getTransportProfileUri(), context.getTransportProfile().getUri()); - - boolean pathMatch = - java.util.Objects.equals( - EndpointUtil.getPath(e.getEndpointUrl()), - EndpointUtil.getPath(context.getEndpointUrl())); - - boolean securityPolicyMatch = - java.util.Objects.equals( - e.getSecurityPolicyUri(), - context.getSecureChannel().getSecurityPolicy().getUri()); - - boolean securityModeMatch = - java.util.Objects.equals( - e.getSecurityMode(), context.getSecureChannel().getMessageSecurityMode()); - - return transportMatch && pathMatch && securityPolicyMatch && securityModeMatch; - }) - .findFirst() + + Optional selectedEndpoint = context.getEndpoint(); + + if (selectedEndpoint.isPresent()) { + return selectedEndpoint.get(); + } + + SecureChannel secureChannel = context.getSecureChannel(); + SecurityPolicy securityPolicy = secureChannel.getSecurityPolicy(); + + ByteString certificateThumbprint = ByteString.NULL_VALUE; + if (securityPolicy != SecurityPolicy.None) { + certificateThumbprint = ByteString.of(sha1(secureChannel.getLocalCertificateBytes().bytes())); + } + + EndpointSelectionKey selectionKey = + EndpointSelectionKey.of( + context.getTransportProfile(), + context.getEndpointUrl(), + securityPolicy, + secureChannel.getMessageSecurityMode(), + certificateThumbprint); + + return server + .getApplicationContext() + .selectEndpoint(selectionKey, context.getEndpointUrl()) .orElseThrow( () -> { String message = @@ -1068,8 +1088,8 @@ private EndpointDescription findSessionEndpoint(ServiceRequestContext context) + "endpointUrl=%s, securityPolicy=%s, securityMode=%s", context.getTransportProfile(), context.getEndpointUrl(), - context.getSecureChannel().getSecurityPolicy(), - context.getSecureChannel().getMessageSecurityMode()); + securityPolicy, + secureChannel.getMessageSecurityMode()); return new UaException(StatusCodes.Bad_SecurityChecksFailed, message); }); diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java index ff85753b0f..b89d34e5a6 100644 --- a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/OpcUaServerConfigTest.java @@ -72,7 +72,9 @@ public void testCopy() { OpcUaServerConfig copy = OpcUaServerConfig.copy(original).build(); - assertSame(original.getEndpoints(), copy.getEndpoints()); + // The builder defensively copies the endpoint set (like reverse-connect targets), so copies + // are equal but not the same instance. + assertEquals(original.getEndpoints(), copy.getEndpoints()); assertEquals(original.getReverseConnectTargets(), copy.getReverseConnectTargets()); assertSame(original.getApplicationName(), copy.getApplicationName()); assertEquals(original.getApplicationUri(), copy.getApplicationUri()); diff --git a/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java new file mode 100644 index 0000000000..2708093cb6 --- /dev/null +++ b/opc-ua-sdk/sdk-server/src/test/java/org/eclipse/milo/opcua/sdk/server/SessionEndpointBindingTest.java @@ -0,0 +1,741 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.eclipse.milo.opcua.sdk.server; + +import static org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.netty.channel.Channel; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.eclipse.milo.opcua.stack.core.NodeIds; +import org.eclipse.milo.opcua.stack.core.StatusCodes; +import org.eclipse.milo.opcua.stack.core.UaException; +import org.eclipse.milo.opcua.stack.core.channel.SecureChannel; +import org.eclipse.milo.opcua.stack.core.channel.ServerSecureChannel; +import org.eclipse.milo.opcua.stack.core.security.CertificateFactory; +import org.eclipse.milo.opcua.stack.core.security.CertificateGroup; +import org.eclipse.milo.opcua.stack.core.security.CertificateManager; +import org.eclipse.milo.opcua.stack.core.security.CertificateValidator; +import org.eclipse.milo.opcua.stack.core.security.DefaultCertificateManager; +import org.eclipse.milo.opcua.stack.core.security.MemoryCertificateQuarantine; +import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy; +import org.eclipse.milo.opcua.stack.core.security.TrustListManager; +import org.eclipse.milo.opcua.stack.core.transport.TransportProfile; +import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString; +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.builtin.NodeId; +import org.eclipse.milo.opcua.stack.core.types.enumerated.ApplicationType; +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.ActivateSessionRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.ApplicationDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.CreateSessionRequest; +import org.eclipse.milo.opcua.stack.core.types.structured.CreateSessionResponse; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; +import org.eclipse.milo.opcua.stack.core.types.structured.RequestHeader; +import org.eclipse.milo.opcua.stack.core.types.structured.SignatureData; +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.SelfSignedCertificateBuilder; +import org.eclipse.milo.opcua.stack.core.util.SelfSignedCertificateGenerator; +import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey; +import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport; +import org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory; +import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext; +import org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel (Part 6, + * 7.1.2.3), so the server must derive endpoint identity from wire-observable channel inputs. These + * tests protect the invariant that one selection key identifies exactly one effective Session + * endpoint: ambiguous configurations are rejected at startup, and Sessions are bound to the + * endpoint their SecureChannel selected rather than an ordering-dependent re-derivation. + */ +public class SessionEndpointBindingTest { + + private static final NodeId GROUP_A = new NodeId(2, "certificate-group-a"); + private static final NodeId GROUP_B = new NodeId(2, "certificate-group-b"); + + private static final UserTokenPolicy ANONYMOUS_POLICY = + new UserTokenPolicy("anonymous", UserTokenType.Anonymous, null, null, null); + + private static final UserTokenPolicy USERNAME_POLICY = + new UserTokenPolicy("username", UserTokenType.UserName, null, null, null); + + private static final OpcServerTransportFactory NO_OP_TRANSPORTS = + transportProfile -> + new OpcServerTransport() { + @Override + public void bind( + ServerApplicationContext applicationContext, InetSocketAddress bindAddress) {} + + @Override + public void unbind() {} + }; + + private static CertificateMaterial certificateA; + private static CertificateMaterial certificateB; + private static CertificateMaterial clientCertificate; + + @BeforeAll + static void generateCertificates() throws Exception { + certificateA = rsaCertificate("server-a"); + certificateB = rsaCertificate("server-b"); + clientCertificate = rsaCertificate("client"); + } + + @Nested + class StartupValidation { + + /** + * Part 4, 5.5.4.1 distinguishes endpoints by security configuration, while user token policies + * are advertised properties of an endpoint, not selectors. Two endpoints indistinguishable at + * OpenSecureChannel that differ only in token policies would make Session authentication depend + * on endpoint collection ordering, so startup must reject them in either insertion order rather + * than document a sharp edge. + */ + @Test + void startupRejectsEndpointsDifferingOnlyInUserTokenPoliciesInEitherOrder() { + EndpointConfig anonymousEndpoint = securedEndpoint(GROUP_A, ANONYMOUS_POLICY); + EndpointConfig usernameEndpoint = securedEndpoint(GROUP_A, USERNAME_POLICY); + + for (List ordering : + List.of( + List.of(anonymousEndpoint, usernameEndpoint), + List.of(usernameEndpoint, anonymousEndpoint))) { + + OpcUaServer server = server(manager(group(GROUP_A, certificateA)), ordering); + + ExecutionException e = + assertThrows( + ExecutionException.class, + () -> server.startup().get(5, TimeUnit.SECONDS), + "ordering: " + ordering); + + UaException cause = assertInstanceOf(UaException.class, e.getCause()); + assertEquals(StatusCodes.Bad_ConfigurationError, cause.getStatusCode().value()); + assertTrue( + cause.getMessage().contains("userTokenPolicies"), + "collision diagnostic should identify the colliding endpoints: " + cause.getMessage()); + } + } + + // The supported alternative to per-token-policy endpoints: one endpoint advertising all + // supported user token policies must be accepted. + @Test + void singleEndpointWithMultipleUserTokenPoliciesIsAccepted() throws Exception { + OpcUaServer server = + server( + manager(group(GROUP_A, certificateA)), + List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY, USERNAME_POLICY))); + + assertEquals(server, server.startup().get(5, TimeUnit.SECONDS)); + + server.shutdown().get(5, TimeUnit.SECONDS); + } + + /** + * Otherwise-identical endpoints backed by distinct certificates in distinct CertificateGroups + * are distinguishable by the receiver thumbprint at OpenSecureChannel, so they are a valid + * configuration and must resolve uniquely regardless of insertion order. + */ + @Test + void endpointsWithDistinctCertificatesStartAndResolveUniquelyInEitherOrder() throws Exception { + EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY); + EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY); + + for (List ordering : + List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) { + + OpcUaServer server = + server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering); + + assertEquals(server, server.startup().get(5, TimeUnit.SECONDS), "ordering: " + ordering); + + for (CertificateMaterial certificate : List.of(certificateA, certificateB)) { + EndpointDescription selected = + selectSecuredEndpoint(server, CertificateUtil.thumbprint(certificate.certificate())) + .orElseThrow(); + + assertArrayEquals( + certificate.certificate().getEncoded(), + selected.getServerCertificate().bytesOrEmpty(), + "selection must follow the thumbprint, not insertion order: " + ordering); + } + + server.shutdown().get(5, TimeUnit.SECONDS); + } + } + + /** + * Multi-hostname configurations advertise one endpoint per hostname with identical + * Session-sensitive state. These are host substitution aliases of the same effective endpoint + * (Part 6 permits clients to connect by IP or alternate hostname), not collisions, and the + * alias matching the client's requested URL is preferred. + */ + @Test + void hostnameAliasesAreAcceptedAndResolvedByRequestedUrl() throws Exception { + EndpointConfig alphaEndpoint = securedEndpointForHostname("alpha", GROUP_A); + EndpointConfig betaEndpoint = securedEndpointForHostname("beta", GROUP_A); + + OpcUaServer server = + server(manager(group(GROUP_A, certificateA)), List.of(alphaEndpoint, betaEndpoint)); + + assertEquals(server, server.startup().get(5, TimeUnit.SECONDS)); + + EndpointSelectionKey key = + EndpointSelectionKey.of( + TransportProfile.TCP_UASC_UABINARY, + "opc.tcp://beta:4840/test", + SecurityPolicy.Basic256Sha256, + MessageSecurityMode.SignAndEncrypt, + CertificateUtil.thumbprint(certificateA.certificate())); + + EndpointDescription selected = + server + .getApplicationContext() + .selectEndpoint(key, "opc.tcp://beta:4840/test") + .orElseThrow(); + + assertEquals("opc.tcp://beta:4840/test", selected.getEndpointUrl()); + + server.shutdown().get(5, TimeUnit.SECONDS); + } + + /** + * An unsecured channel may only select an explicit SecurityPolicy.None endpoint. When none is + * configured the selection is empty -- the discovery-only state -- rather than an arbitrary + * secured endpoint whose certificate and token policies the channel never negotiated. + */ + @Test + void unsecuredKeySelectsNothingWhenNoExplicitNoneEndpointExists() { + OpcUaServer server = + server( + manager(group(GROUP_A, certificateA)), + List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY))); + + Optional selected = + server.getApplicationContext().selectEndpoint(noneKey("/test"), endpointUrl("/test")); + + assertTrue(selected.isEmpty()); + } + } + + @Nested + class SessionBinding { + + /** + * The certificate returned by CreateSession must belong to the endpoint whose thumbprint + * established the SecureChannel. Before endpoint propagation, SessionManager re-derived the + * endpoint without a certificate discriminator and could return the other endpoint's + * certificate depending on insertion order. + */ + @Test + void createSessionBindsSessionToChannelSelectedEndpointInEitherOrder() throws Exception { + EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY); + EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY); + + for (List ordering : + List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) { + + OpcUaServer server = + server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering); + + // The channel was established against certificate B; the transport propagates the + // endpoint it selected by receiver thumbprint. + EndpointDescription channelEndpoint = + selectSecuredEndpoint(server, CertificateUtil.thumbprint(certificateB.certificate())) + .orElseThrow(); + + ServiceRequestContext context = + new TestServiceRequestContext( + endpointUrl("/test"), securedChannel(1L, certificateB), channelEndpoint); + + CreateSessionResponse response = + server + .getSessionManager() + .createSession(context, createSessionRequest(clientCertificate.byteString())); + + assertArrayEquals( + certificateB.certificate().getEncoded(), + response.getServerCertificate().bytesOrEmpty(), + "CreateSession must return the channel endpoint's certificate; ordering: " + ordering); + + Session session = server.getSessionManager().getAllSessions().get(0); + assertEquals(channelEndpoint, session.getEndpoint()); + + session.close(true); + } + } + + /** + * Contexts that do not propagate a channel endpoint selection fall back to selection-key + * resolution, which discriminates by the channel certificate. The result must be the endpoint + * matching the channel certificate in either insertion order -- the direct regression test for + * ordering-dependent findFirst() selection. + */ + @Test + void fallbackEndpointResolutionFollowsChannelCertificateInEitherOrder() throws Exception { + EndpointConfig endpointA = securedEndpoint(GROUP_A, ANONYMOUS_POLICY); + EndpointConfig endpointB = securedEndpoint(GROUP_B, ANONYMOUS_POLICY); + + for (List ordering : + List.of(List.of(endpointA, endpointB), List.of(endpointB, endpointA))) { + + OpcUaServer server = + server(manager(group(GROUP_A, certificateA), group(GROUP_B, certificateB)), ordering); + + ServiceRequestContext context = + new TestServiceRequestContext( + endpointUrl("/test"), securedChannel(1L, certificateB), null); + + CreateSessionResponse response = + server + .getSessionManager() + .createSession(context, createSessionRequest(clientCertificate.byteString())); + + assertArrayEquals( + certificateB.certificate().getEncoded(), + response.getServerCertificate().bytesOrEmpty(), + "fallback resolution must follow the channel certificate; ordering: " + ordering); + + server.getSessionManager().getAllSessions().forEach(s -> s.close(true)); + } + } + + /** + * A SecurityPolicy.None channel with no explicit None endpoint supports discovery only. + * CreateSession on such a channel must be rejected instead of associating the Session with an + * arbitrary secured endpoint's token policies and certificate. + */ + @Test + void createSessionOnDiscoveryOnlyUnsecuredChannelIsRejected() { + OpcUaServer server = + server( + manager(group(GROUP_A, certificateA)), + List.of(securedEndpoint(GROUP_A, ANONYMOUS_POLICY))); + + ServiceRequestContext context = + new TestServiceRequestContext(endpointUrl("/test"), noneChannel(1L), null); + + UaException e = + assertThrows( + UaException.class, + () -> + server + .getSessionManager() + .createSession(context, createSessionRequest(ByteString.NULL_VALUE))); + + assertEquals(StatusCodes.Bad_SecurityChecksFailed, e.getStatusCode().value()); + } + + // An explicitly configured SecurityPolicy.None endpoint continues to support unsecured + // Sessions, including for contexts that do not propagate a channel endpoint selection. + @Test + void createSessionOnExplicitNoneEndpointSucceeds() throws Exception { + OpcUaServer server = + server( + manager(group(GROUP_A, certificateA)), + List.of(noneEndpoint("/test"), securedEndpoint(GROUP_A, ANONYMOUS_POLICY))); + + ServiceRequestContext context = + new TestServiceRequestContext(endpointUrl("/test"), noneChannel(1L), null); + + server + .getSessionManager() + .createSession(context, createSessionRequest(ByteString.NULL_VALUE)); + + Session session = server.getSessionManager().getAllSessions().get(0); + assertEquals(SecurityPolicy.None.getUri(), session.getEndpoint().getSecurityPolicyUri()); + + session.close(true); + } + + /** + * When a Session is reactivated onto a replacement SecureChannel, its endpoint association must + * follow the endpoint selected by that new channel; identity validation runs against the new + * endpoint's token policies before the Session's security state is changed. + */ + @Test + void reactivationOnReplacementChannelUsesThatChannelsEndpoint() throws Exception { + OpcUaServer server = + server( + manager(group(GROUP_A, certificateA)), + List.of(noneEndpoint("/a"), noneEndpoint("/b"))); + + EndpointDescription endpointA = endpointForPath(server, "/a"); + EndpointDescription endpointB = endpointForPath(server, "/b"); + + ServiceRequestContext context1 = + new TestServiceRequestContext(endpointUrl("/a"), noneChannel(1L), endpointA); + + CreateSessionResponse createResponse = + server + .getSessionManager() + .createSession(context1, createSessionRequest(ByteString.NULL_VALUE)); + + NodeId authToken = createResponse.getAuthenticationToken(); + + server.getSessionManager().activateSession(context1, activateSessionRequest(authToken)); + + Session session = server.getSessionManager().getAllSessions().get(0); + assertEquals(endpointA, session.getEndpoint()); + assertNotEquals(endpointA, endpointB, "control: the two endpoints must differ"); + + ServiceRequestContext context2 = + new TestServiceRequestContext(endpointUrl("/b"), noneChannel(2L), endpointB); + + server.getSessionManager().activateSession(context2, activateSessionRequest(authToken)); + + assertEquals(endpointB, session.getEndpoint(), "session must follow the new channel"); + assertEquals(2L, session.getSecureChannelId()); + + session.close(true); + } + } + + private static Optional selectSecuredEndpoint( + OpcUaServer server, ByteString thumbprint) { + + EndpointSelectionKey key = + EndpointSelectionKey.of( + TransportProfile.TCP_UASC_UABINARY, + endpointUrl("/test"), + SecurityPolicy.Basic256Sha256, + MessageSecurityMode.SignAndEncrypt, + thumbprint); + + return server.getApplicationContext().selectEndpoint(key, endpointUrl("/test")); + } + + private static EndpointSelectionKey noneKey(String path) { + return EndpointSelectionKey.of( + TransportProfile.TCP_UASC_UABINARY, + endpointUrl(path), + SecurityPolicy.None, + MessageSecurityMode.None, + null); + } + + private static EndpointDescription endpointForPath(OpcUaServer server, String path) { + return server.getApplicationContext().getEndpointDescriptions().stream() + .filter(e -> e.getEndpointUrl().endsWith(path)) + .findFirst() + .orElseThrow(); + } + + private static String endpointUrl(String path) { + return "opc.tcp://localhost:4840" + path; + } + + private static EndpointConfig securedEndpoint(NodeId groupId, UserTokenPolicy... tokenPolicies) { + return EndpointConfig.newBuilder() + .setBindAddress("localhost") + .setBindPort(4840) + .setHostname("localhost") + .setPath("/test") + .setSecurityPolicy(SecurityPolicy.Basic256Sha256) + .setSecurityMode(MessageSecurityMode.SignAndEncrypt) + .setEndpointCertificateConfig( + EndpointCertificateConfig.newBuilder().setCertificateGroupId(groupId).build()) + .addTokenPolicies(tokenPolicies) + .build(); + } + + private static EndpointConfig securedEndpointForHostname(String hostname, NodeId groupId) { + return EndpointConfig.newBuilder() + .setBindAddress("localhost") + .setBindPort(4840) + .setHostname(hostname) + .setPath("/test") + .setSecurityPolicy(SecurityPolicy.Basic256Sha256) + .setSecurityMode(MessageSecurityMode.SignAndEncrypt) + .setEndpointCertificateConfig( + EndpointCertificateConfig.newBuilder().setCertificateGroupId(groupId).build()) + .addTokenPolicy(ANONYMOUS_POLICY) + .build(); + } + + private static EndpointConfig noneEndpoint(String path) { + return EndpointConfig.newBuilder() + .setBindAddress("localhost") + .setBindPort(4840) + .setHostname("localhost") + .setPath(path) + .setSecurityPolicy(SecurityPolicy.None) + .setSecurityMode(MessageSecurityMode.None) + .addTokenPolicy(ANONYMOUS_POLICY) + .build(); + } + + private static OpcUaServer server( + CertificateManager certificateManager, List endpoints) { + + OpcUaServerConfig config = + OpcUaServerConfig.builder() + .setEndpoints(new LinkedHashSet<>(endpoints)) + .setCertificateManager(certificateManager) + .setApplicationUri("urn:test:server") + .setProductUri("urn:test:product") + .build(); + + return new OpcUaServer(config, NO_OP_TRANSPORTS); + } + + private static ServerSecureChannel noneChannel(long channelId) { + var secureChannel = new ServerSecureChannel(); + secureChannel.setChannelId(channelId); + secureChannel.setSecurityPolicy(SecurityPolicy.None); + secureChannel.setMessageSecurityMode(MessageSecurityMode.None); + return secureChannel; + } + + private static ServerSecureChannel securedChannel( + long channelId, CertificateMaterial serverCertificate) throws Exception { + + var secureChannel = new ServerSecureChannel(); + secureChannel.setChannelId(channelId); + secureChannel.setSecurityPolicy(SecurityPolicy.Basic256Sha256); + secureChannel.setMessageSecurityMode(MessageSecurityMode.SignAndEncrypt); + secureChannel.setLocalCertificate(serverCertificate.certificate()); + secureChannel.setLocalCertificateChain(serverCertificate.certificateChain()); + secureChannel.setKeyPair(serverCertificate.keyPair()); + secureChannel.setRemoteCertificate(clientCertificate.byteString().bytesOrEmpty()); + return secureChannel; + } + + private static CreateSessionRequest createSessionRequest(ByteString clientCertificateBytes) { + return new CreateSessionRequest( + requestHeader(NodeId.NULL_VALUE), + new ApplicationDescription( + "urn:test:client", + "urn:test:client-product", + LocalizedText.english("client"), + ApplicationType.Client, + null, + null, + null), + null, + endpointUrl("/test"), + "test-session", + NonceUtil.generateNonce(32), + clientCertificateBytes, + 60_000.0, + uint(0)); + } + + private static ActivateSessionRequest activateSessionRequest(NodeId authToken) { + return new ActivateSessionRequest( + requestHeader(authToken), + new SignatureData(null, null), + null, + null, + null, + new SignatureData(null, null)); + } + + private static RequestHeader requestHeader(NodeId authToken) { + return new RequestHeader(authToken, DateTime.now(), uint(1), uint(0), null, uint(10_000), null); + } + + private static CertificateMaterial rsaCertificate(String commonName) throws Exception { + KeyPair keyPair = SelfSignedCertificateGenerator.generateRsaKeyPair(2048); + X509Certificate certificate = + new SelfSignedCertificateBuilder(keyPair) + .setCommonName(commonName) + .setOrganization("Eclipse Milo") + .setApplicationUri("urn:test:" + commonName) + .build(); + + return new CertificateMaterial( + NodeIds.RsaSha256ApplicationCertificateType, keyPair, new X509Certificate[] {certificate}); + } + + private static CertificateManager manager(TestCertificateGroup... groups) { + List certificateGroups = + Arrays.stream(groups).map(CertificateGroup.class::cast).toList(); + + return new DefaultCertificateManager(new MemoryCertificateQuarantine(), certificateGroups); + } + + private static TestCertificateGroup group(NodeId groupId, CertificateMaterial... certificates) { + return new TestCertificateGroup(groupId, List.of(certificates)); + } + + private record CertificateMaterial( + NodeId certificateTypeId, KeyPair keyPair, X509Certificate[] certificateChain) { + + X509Certificate certificate() { + return certificateChain[0]; + } + + ByteString byteString() throws Exception { + return ByteString.of(certificate().getEncoded()); + } + } + + /** A {@link ServiceRequestContext} standing in for a request arriving over a UASC channel. */ + private static final class TestServiceRequestContext implements ServiceRequestContext { + + private final String endpointUrl; + private final SecureChannel secureChannel; + private final @Nullable EndpointDescription endpoint; + + private TestServiceRequestContext( + String endpointUrl, SecureChannel secureChannel, @Nullable EndpointDescription endpoint) { + + this.endpointUrl = endpointUrl; + this.secureChannel = secureChannel; + this.endpoint = endpoint; + } + + @Override + public String getEndpointUrl() { + return endpointUrl; + } + + @Override + public TransportProfile getTransportProfile() { + return TransportProfile.TCP_UASC_UABINARY; + } + + @Override + public Channel getChannel() { + throw new UnsupportedOperationException(); + } + + @Override + public SecureChannel getSecureChannel() { + return secureChannel; + } + + @Override + public Optional getEndpoint() { + return Optional.ofNullable(endpoint); + } + + @Override + public Long receivedAtNanos() { + return System.nanoTime(); + } + + @Override + public InetAddress clientAddress() { + return InetAddress.getLoopbackAddress(); + } + } + + private record TestCertificateGroup( + NodeId certificateGroupId, Map certificates) + implements CertificateGroup { + + private TestCertificateGroup( + NodeId certificateGroupId, List certificates) { + this(certificateGroupId, toCertificateMap(certificates)); + } + + private static Map toCertificateMap( + List certificates) { + + Map certificateMap = + certificates.stream() + .collect( + Collectors.toMap( + CertificateMaterial::certificateTypeId, + Function.identity(), + (left, right) -> right, + LinkedHashMap::new)); + + return Collections.unmodifiableMap(certificateMap); + } + + @Override + public NodeId getCertificateGroupId() { + return certificateGroupId; + } + + @Override + public List getSupportedCertificateTypeIds() { + return List.copyOf(certificates.keySet()); + } + + @Override + public TrustListManager getTrustListManager() { + throw new UnsupportedOperationException(); + } + + @Override + public List getCertificateEntries() { + return certificates.values().stream() + .map( + certificate -> + new CertificateGroup.Entry( + certificateGroupId, + certificate.certificateTypeId(), + certificate.certificateChain())) + .toList(); + } + + @Override + public Optional getKeyPair(NodeId certificateTypeId) { + return Optional.ofNullable(certificates.get(certificateTypeId)) + .map(CertificateMaterial::keyPair); + } + + @Override + public Optional getCertificateChain(NodeId certificateTypeId) { + return Optional.ofNullable(certificates.get(certificateTypeId)) + .map(CertificateMaterial::certificateChain) + .map(X509Certificate[]::clone); + } + + @Override + public void updateCertificate( + NodeId certificateTypeId, KeyPair keyPair, X509Certificate[] certificateChain) { + throw new UnsupportedOperationException(); + } + + @Override + public CertificateFactory getCertificateFactory() { + throw new UnsupportedOperationException(); + } + + @Override + public CertificateValidator getCertificateValidator() { + return new CertificateValidator.InsecureCertificateValidator(); + } + } +} diff --git a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java index 64dd816c68..4f7f74edde 100644 --- a/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java +++ b/opc-ua-stack/stack-core/src/main/java/org/eclipse/milo/opcua/stack/core/util/PShaUtil.java @@ -16,7 +16,7 @@ import org.eclipse.milo.opcua.stack.core.UaRuntimeException; /** - * + * Implements the P_SHA pseudo-random functions used to derive symmetric keys. * *

  * P_SHA-1(secret, seed) =
diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java
new file mode 100644
index 0000000000..dbc5e0351b
--- /dev/null
+++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/EndpointSelectionKey.java
@@ -0,0 +1,249 @@
+/*
+ * Copyright (c) 2026 the Eclipse Milo Authors
+ *
+ * This program and the accompanying materials are made
+ * available under the terms of the Eclipse Public License 2.0
+ * which is available at https://www.eclipse.org/legal/epl-2.0/
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.eclipse.milo.opcua.stack.transport.server;
+
+import static java.util.Objects.requireNonNullElse;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy;
+import org.eclipse.milo.opcua.stack.core.transport.TransportProfile;
+import org.eclipse.milo.opcua.stack.core.types.builtin.ByteString;
+import org.eclipse.milo.opcua.stack.core.types.enumerated.MessageSecurityMode;
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription;
+import org.eclipse.milo.opcua.stack.core.types.structured.UserTokenPolicy;
+import org.eclipse.milo.opcua.stack.core.util.DigestUtil;
+import org.eclipse.milo.opcua.stack.core.util.EndpointUtil;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * The wire-observable identity of a server endpoint at OpenSecureChannel time.
+ *
+ * 

OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel (Part 6, + * 7.1.2.3: the Hello message carries only an endpoint URL), so a server must derive which + * configured endpoint a new SecureChannel belongs to from the inputs the client does send. This key + * captures exactly those inputs: + * + *

    + *
  • the transport profile the connection arrived on + *
  • the normalized path of the endpoint URL from the Hello message (host and port are excluded + * because clients may validly substitute them, e.g. connecting by IP address) + *
  • the SecurityPolicy from the AsymmetricSecurityHeader + *
  • the MessageSecurityMode from the OpenSecureChannel request + *
  • the SHA-1 thumbprint of the endpoint application certificate (the receiver thumbprint from + * the AsymmetricSecurityHeader); absent for {@link SecurityPolicy#None}, where no certificate + * is observable on the wire + *
+ * + *

The intended invariant is that each key identifies exactly one effective Session endpoint. + * {@link UserTokenPolicy}s are deliberately not part of the key: they are properties of the + * selected endpoint, not selectors. Endpoints that share a key must agree on every + * Session-sensitive property (see {@link #isSessionEquivalent(EndpointDescription, + * EndpointDescription)}); they may differ only in the substitutable host/port portion of their + * endpoint URL. + * + * @param transportProfileUri the URI of the {@link TransportProfile} the connection uses. + * @param path the normalized endpoint URL path, as returned by {@link EndpointUtil#getPath}. + * @param securityPolicyUri the URI of the channel {@link SecurityPolicy}. + * @param securityMode the {@link MessageSecurityMode} requested for the channel. + * @param certificateThumbprint the SHA-1 thumbprint of the endpoint application certificate, or + * {@link ByteString#NULL_VALUE} for {@link SecurityPolicy#None}. + */ +public record EndpointSelectionKey( + String transportProfileUri, + String path, + String securityPolicyUri, + MessageSecurityMode securityMode, + ByteString certificateThumbprint) { + + public EndpointSelectionKey { + // Canonicalize so that a null and an empty thumbprint are the same key. ByteString#equals + // already treats them as equal but ByteString#hashCode does not, which would break map lookups. + if (certificateThumbprint == null || certificateThumbprint.isNullOrEmpty()) { + certificateThumbprint = ByteString.NULL_VALUE; + } + } + + /** + * Create the key identifying the endpoint a new SecureChannel selects, from the inputs available + * to the server during OpenSecureChannel. + * + * @param transportProfile the {@link TransportProfile} the connection arrived on. + * @param endpointUrl the endpoint URL from the Hello message. + * @param securityPolicy the {@link SecurityPolicy} from the AsymmetricSecurityHeader. + * @param securityMode the {@link MessageSecurityMode} from the OpenSecureChannel request. + * @param certificateThumbprint the receiver certificate thumbprint from the + * AsymmetricSecurityHeader; ignored for {@link SecurityPolicy#None}. + * @return the {@link EndpointSelectionKey} for the channel. + */ + public static EndpointSelectionKey of( + TransportProfile transportProfile, + @Nullable String endpointUrl, + SecurityPolicy securityPolicy, + MessageSecurityMode securityMode, + @Nullable ByteString certificateThumbprint) { + + return new EndpointSelectionKey( + transportProfile.getUri(), + EndpointUtil.getPath(endpointUrl), + securityPolicy.getUri(), + securityMode, + securityPolicy == SecurityPolicy.None ? ByteString.NULL_VALUE : certificateThumbprint); + } + + /** + * Create the key under which {@code endpoint} is selectable. + * + * @param endpoint the {@link EndpointDescription} to derive a key for. + * @return the {@link EndpointSelectionKey} under which {@code endpoint} is selectable. + */ + public static EndpointSelectionKey of(EndpointDescription endpoint) { + String securityPolicyUri = endpoint.getSecurityPolicyUri(); + + ByteString certificateThumbprint = ByteString.NULL_VALUE; + if (!Objects.equals(securityPolicyUri, SecurityPolicy.None.getUri())) { + ByteString serverCertificate = + requireNonNullElse(endpoint.getServerCertificate(), ByteString.NULL_VALUE); + certificateThumbprint = ByteString.of(DigestUtil.sha1(serverCertificate.bytesOrEmpty())); + } + + return new EndpointSelectionKey( + requireNonNullElse(endpoint.getTransportProfileUri(), ""), + EndpointUtil.getPath(endpoint.getEndpointUrl()), + requireNonNullElse(securityPolicyUri, ""), + endpoint.getSecurityMode(), + certificateThumbprint); + } + + /** + * Select the unique endpoint identified by {@code key} from {@code endpoints}. + * + *

Multiple endpoints may share a key only when they are {@link + * #isSessionEquivalent(EndpointDescription, EndpointDescription) Session-equivalent}, i.e. they + * are host/port substitution aliases of the same effective endpoint. In that case the alias whose + * URL best matches {@code requestedEndpointUrl} is preferred (host and port match, then host + * match), falling back to the first alias; every alias carries identical Session-sensitive state, + * so the choice affects only the advertised URL. + * + *

If endpoints sharing the key are not Session-equivalent, no endpoint is returned: the key is + * ambiguous and selecting one arbitrarily would make security-sensitive Session state depend on + * collection ordering. + * + * @param endpoints the candidate {@link EndpointDescription}s. + * @param key the {@link EndpointSelectionKey} to resolve. + * @param requestedEndpointUrl the endpoint URL requested by the client, used to prefer among + * host/port substitution aliases; may be null. + * @return the unique endpoint identified by {@code key}, or empty if there is none or the key is + * ambiguous. + */ + public static Optional selectUnique( + List endpoints, + EndpointSelectionKey key, + @Nullable String requestedEndpointUrl) { + + List candidates = + endpoints.stream().filter(e -> key.equals(EndpointSelectionKey.of(e))).toList(); + + if (candidates.isEmpty()) { + return Optional.empty(); + } + + EndpointDescription first = candidates.get(0); + for (EndpointDescription candidate : candidates) { + if (!isSessionEquivalent(first, candidate)) { + return Optional.empty(); + } + } + + return Optional.of( + preferRequestedUrl(candidates, requestedEndpointUrl, EndpointDescription::getEndpointUrl)); + } + + /** + * Return whether two endpoints sharing a selection key are interchangeable for Session purposes. + * + *

Endpoints sharing a key already agree on transport profile, path, SecurityPolicy, + * MessageSecurityMode, and (for secured policies) certificate thumbprint. What remains + * Session-sensitive is the advertised user token policy set (compared as a set, ignoring + * declaration order) and the advertised certificate itself, which for {@link SecurityPolicy#None} + * endpoints is not covered by the key. + * + * @param a an {@link EndpointDescription}. + * @param b an {@link EndpointDescription} sharing {@code a}'s selection key. + * @return {@code true} if the endpoints differ only in the substitutable host/port portion of + * their endpoint URL. + */ + public static boolean isSessionEquivalent(EndpointDescription a, EndpointDescription b) { + return userTokenPolicies(a).equals(userTokenPolicies(b)) + && Arrays.equals( + requireNonNullElse(a.getServerCertificate(), ByteString.NULL_VALUE).bytesOrEmpty(), + requireNonNullElse(b.getServerCertificate(), ByteString.NULL_VALUE).bytesOrEmpty()); + } + + private static Set userTokenPolicies(EndpointDescription endpoint) { + UserTokenPolicy[] tokens = endpoint.getUserIdentityTokens(); + return tokens == null ? Set.of() : new HashSet<>(Arrays.asList(tokens)); + } + + /** + * Select from {@code candidates} the element whose endpoint URL best matches {@code + * requestedEndpointUrl}: an exact host and port match is preferred, then a host-only match, + * falling back to the first candidate. + * + *

Intended for choosing among host/port substitution aliases that share a selection key, so + * the endpoint URL advertised to a client reflects the URL it actually requested. + * + * @param candidates the candidate elements; must not be empty. + * @param requestedEndpointUrl the endpoint URL requested by the client; may be null. + * @param endpointUrl extracts a candidate's endpoint URL; may return null. + * @param the candidate type. + * @return the candidate whose endpoint URL best matches {@code requestedEndpointUrl}, or the + * first candidate if none match. + */ + public static T preferRequestedUrl( + List candidates, + @Nullable String requestedEndpointUrl, + Function endpointUrl) { + + if (requestedEndpointUrl != null) { + String requestedHost = EndpointUtil.getHost(requestedEndpointUrl); + int requestedPort = EndpointUtil.getPort(requestedEndpointUrl); + + if (requestedHost != null) { + T hostMatch = null; + + for (T candidate : candidates) { + String url = requireNonNullElse(endpointUrl.apply(candidate), ""); + + if (requestedHost.equalsIgnoreCase(EndpointUtil.getHost(url))) { + if (requestedPort == EndpointUtil.getPort(url)) { + return candidate; + } + if (hostMatch == null) { + hostMatch = candidate; + } + } + } + + if (hostMatch != null) { + return hostMatch; + } + } + } + + return candidates.get(0); + } +} diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java index 57e9f78454..a5fd1288e0 100644 --- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServerApplicationContext.java @@ -11,6 +11,7 @@ package org.eclipse.milo.opcua.stack.transport.server; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import org.eclipse.milo.opcua.stack.core.channel.SecurityKeysListener; import org.eclipse.milo.opcua.stack.core.encoding.EncodingContext; @@ -29,6 +30,25 @@ public interface ServerApplicationContext { */ List getEndpointDescriptions(); + /** + * Select the unique {@link EndpointDescription} identified by {@code key}. + * + *

Used by the server transport to associate exactly one endpoint with a SecureChannel during + * OpenSecureChannel. Implementations must never choose arbitrarily among multiple non-equivalent + * endpoints matching {@code key}: if the key is ambiguous, they must return empty. + * + * @param key the {@link EndpointSelectionKey} derived from the channel's wire-observable inputs. + * @param requestedEndpointUrl the endpoint URL requested by the client, used to prefer among + * host/port substitution aliases of the same effective endpoint; may be null. + * @return the unique endpoint identified by {@code key}, or empty if there is none or the key is + * ambiguous. + */ + default Optional selectEndpoint( + EndpointSelectionKey key, @Nullable String requestedEndpointUrl) { + + return EndpointSelectionKey.selectUnique(getEndpointDescriptions(), key, requestedEndpointUrl); + } + /** * Get the server's {@link CertificateManager}. * diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java index 4103b5cb09..bc2af17d72 100644 --- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequest.java @@ -11,9 +11,12 @@ package org.eclipse.milo.opcua.stack.transport.server; import io.netty.channel.Channel; +import java.util.Optional; import org.eclipse.milo.opcua.stack.core.channel.SecureChannel; import org.eclipse.milo.opcua.stack.core.transport.TransportProfile; import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; +import org.jspecify.annotations.Nullable; /** Holds a received {@link UaRequestMessageType} with some additional transport layer details. */ public class ServiceRequest implements ServiceRequestContext { @@ -24,6 +27,7 @@ public class ServiceRequest implements ServiceRequestContext { private final TransportProfile transportProfile; private final Channel channel; private final SecureChannel secureChannel; + private final @Nullable EndpointDescription endpoint; private final UaRequestMessageType requestMessage; public ServiceRequest( @@ -31,12 +35,14 @@ public ServiceRequest( TransportProfile transportProfile, Channel channel, SecureChannel secureChannel, + @Nullable EndpointDescription endpoint, UaRequestMessageType requestMessage) { this.endpointUrl = endpointUrl; this.transportProfile = transportProfile; this.channel = channel; this.secureChannel = secureChannel; + this.endpoint = endpoint; this.requestMessage = requestMessage; } @@ -60,6 +66,11 @@ public SecureChannel getSecureChannel() { return secureChannel; } + @Override + public Optional getEndpoint() { + return Optional.ofNullable(endpoint); + } + @Override public Long receivedAtNanos() { return receivedAtNanos; diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java index 73465f4409..e254613594 100644 --- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/ServiceRequestContext.java @@ -13,9 +13,11 @@ import io.netty.channel.Channel; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.util.Optional; import org.eclipse.milo.opcua.stack.core.channel.SecureChannel; import org.eclipse.milo.opcua.stack.core.transport.TransportProfile; import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; /** Transport layer details that accompany an inbound {@link UaRequestMessageType}. */ public interface ServiceRequestContext { @@ -51,6 +53,22 @@ public interface ServiceRequestContext { */ SecureChannel getSecureChannel(); + /** + * Get the {@link EndpointDescription} the transport selected for the secure channel this request + * arrived on. + * + *

The endpoint is selected once, during the initial OpenSecureChannel, from the channel's + * wire-observable inputs (see {@link EndpointSelectionKey}), and is the endpoint any Session + * created or activated on the channel must be associated with. + * + *

Empty when no endpoint is associated with the channel: an unsecured channel that matched no + * explicit SecurityPolicy.None endpoint is a discovery-only channel. + * + * @return the {@link EndpointDescription} selected for the channel, or empty if none is + * associated. + */ + Optional getEndpoint(); + /** * Get the system time, in nanos, that this request was received at. * diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java new file mode 100644 index 0000000000..5771588047 --- /dev/null +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/package-info.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 the Eclipse Milo Authors + * + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + */ + +/** + * Server-side transport SPI: the boundary between transport implementations and the server + * application that consumes their service requests. + * + *

A transport implementation ({@link + * org.eclipse.milo.opcua.stack.transport.server.OpcServerTransport}, created by {@link + * org.eclipse.milo.opcua.stack.transport.server.OpcServerTransportFactory}) accepts connections and + * delivers each decoded request to the application through {@link + * org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext#handleServiceRequest}. The + * application supplies everything security-relevant the transport needs: the advertised {@link + * org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription}s, the {@link + * org.eclipse.milo.opcua.stack.core.security.CertificateManager}, and the encoding context. + * + *

Endpoint selection and propagation

+ * + *

OPC UA does not transmit an EndpointDescription identifier during OpenSecureChannel, so the + * endpoint a SecureChannel belongs to must be derived from wire-observable inputs, captured by + * {@link org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey}. During the initial + * OpenSecureChannel the transport resolves its key through {@link + * org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext#selectEndpoint}, which + * yields exactly one endpoint or none -- never an ordering-dependent choice among several. The + * selected endpoint then accompanies every inbound request via {@link + * org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext#getEndpoint}, so the + * application layers (Session creation and activation in particular) consume the channel's + * selection instead of re-deriving it. + * + *

An unsecured (SecurityPolicy.None) channel that matches no explicit None endpoint carries no + * endpoint selection at all: it is a discovery-only channel, on which the application may still + * choose to serve Discovery services but must not create Sessions. + * + *

{@link org.eclipse.milo.opcua.stack.transport.server.ServiceRequest} is the concrete carrier + * of a request and its transport details; {@link + * org.eclipse.milo.opcua.stack.transport.server.ServiceRequestContext} is the read-only view handed + * to the application. + */ +package org.eclipse.milo.opcua.stack.transport.server; diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java index 64ec903c3a..274ac0bf78 100644 --- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerAsymmetricHandler.java @@ -72,6 +72,7 @@ import org.eclipse.milo.opcua.stack.core.util.DigestUtil; import org.eclipse.milo.opcua.stack.core.util.EndpointUtil; import org.eclipse.milo.opcua.stack.core.util.NonceUtil; +import org.eclipse.milo.opcua.stack.transport.server.EndpointSelectionKey; import org.eclipse.milo.opcua.stack.transport.server.ServerApplicationContext; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; @@ -259,6 +260,16 @@ private void onOpenSecureChannel(ChannelHandlerContext ctx, ByteBuf buffer) thro secureChannel.setSecurityPolicy(securityPolicy); if (securityPolicy != SecurityPolicy.None) { + if (header.getReceiverThumbprint().isNullOrEmpty()) { + // Part 6 6.7.2.3: the receiver certificate thumbprint identifies the public key used + // to encrypt the message and is required whenever the OPN is asymmetrically secured. + // Reject explicitly rather than relying on certificate or endpoint lookups to fail, + // so the client receives an ERR message identifying the actual problem. + throw new UaException( + StatusCodes.Bad_SecurityChecksFailed, + "receiverCertificateThumbprint must be present when SecurityPolicy is not None"); + } + CertificateManager certificateManager = application.getCertificateManager(); Optional localCertificateChain = @@ -537,59 +548,39 @@ private OpenSecureChannelResponse openSecureChannel( String endpointUrl = ctx.channel().attr(UascServerHelloHandler.ENDPOINT_URL_KEY).get(); - EndpointDescription endpoint = - application.getEndpointDescriptions().stream() - .filter( - e -> { - boolean transportMatch = - Objects.equals(e.getTransportProfileUri(), transportProfile.getUri()); - - boolean pathMatch = - Objects.equals( - EndpointUtil.getPath(e.getEndpointUrl()), - EndpointUtil.getPath(endpointUrl)); - - boolean securityPolicyMatch = - Objects.equals( - e.getSecurityPolicyUri(), secureChannel.getSecurityPolicy().getUri()); - - boolean securityModeMatch = - Objects.equals(e.getSecurityMode(), request.getSecurityMode()); - - boolean thumbprintMatch = true; - if (!header.getReceiverThumbprint().isNullOrEmpty()) { - thumbprintMatch = - Arrays.equals( - DigestUtil.sha1(e.getServerCertificate().bytesOrEmpty()), - header.getReceiverThumbprint().bytesOrEmpty()); - } - - // allow a matched endpoint OR any unsecured connection, regardless of the - // endpoint security, so that the receiving ServerApplication can decide if - // it wants to allow unsecured Discovery services. - return transportMatch - && pathMatch - && thumbprintMatch - && (securityPolicyMatch && securityModeMatch - || secureChannel.getSecurityPolicy() == SecurityPolicy.None); - }) - .findFirst() - .orElseThrow( - () -> { - String message = - String.format( - "no matching endpoint found: transportProfile=%s, endpointUrl=%s," - + " thumbprint=%s, securityPolicy=%s, securityMode=%s", - transportProfile, - endpointUrl, - header.getReceiverThumbprint(), - secureChannel.getSecurityPolicy(), - request.getSecurityMode()); - - return new UaException(StatusCodes.Bad_SecurityChecksFailed, message); - }); - - ctx.channel().attr(ENDPOINT_KEY).set(endpoint); + SecurityPolicy securityPolicy = secureChannel.getSecurityPolicy(); + + EndpointSelectionKey selectionKey = + EndpointSelectionKey.of( + transportProfile, + endpointUrl, + securityPolicy, + request.getSecurityMode(), + header.getReceiverThumbprint()); + + Optional endpoint = + application.selectEndpoint(selectionKey, endpointUrl); + + if (endpoint.isPresent()) { + ctx.channel().attr(ENDPOINT_KEY).set(endpoint.get()); + } else if (securityPolicy != SecurityPolicy.None) { + String message = + String.format( + "no matching endpoint found: transportProfile=%s, endpointUrl=%s," + + " thumbprint=%s, securityPolicy=%s, securityMode=%s", + transportProfile, + endpointUrl, + header.getReceiverThumbprint(), + securityPolicy, + request.getSecurityMode()); + + throw new UaException(StatusCodes.Bad_SecurityChecksFailed, message); + } + // else: an unsecured channel that matched no explicit SecurityPolicy.None endpoint stays + // open with no endpoint association. This is a discovery-only state: the receiving + // ServerApplication decides whether to allow unsecured Discovery services, and no arbitrary + // secured endpoint is selected to represent the channel. The pre-decryption check in + // onOpenSecureChannel already guaranteed some endpoint exists for this transport and path. } else if (requestType == SecurityTokenRequestType.Renew) { if (secureChannel.getMessageSecurityMode() != request.getSecurityMode()) { throw new UaException( diff --git a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java index c53f8b4762..f4c35b24e1 100644 --- a/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java +++ b/opc-ua-stack/transport/src/main/java/org/eclipse/milo/opcua/stack/transport/server/uasc/UascServerSymmetricHandler.java @@ -38,6 +38,7 @@ import org.eclipse.milo.opcua.stack.core.types.UaRequestMessageType; import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime; import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode; +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription; import org.eclipse.milo.opcua.stack.core.types.structured.ResponseHeader; import org.eclipse.milo.opcua.stack.core.types.structured.ServiceFault; import org.eclipse.milo.opcua.stack.core.util.BufferUtil; @@ -207,12 +208,16 @@ private void onSecureMessage(ChannelHandlerContext ctx, ByteBuf buffer, List