From 656615620de81ba1b36a9a7e0ce5fbbdf42b32b7 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 13:20:44 +0200 Subject: [PATCH 1/4] Require exact LDS certificate ApplicationUri registration (OPC 10000-4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Opc.Ua.Lds.Server/LdsServer.cs | 39 ++-- .../RegistrationValidationTests.cs | 170 ++++++++++++++++++ 2 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs diff --git a/src/Opc.Ua.Lds.Server/LdsServer.cs b/src/Opc.Ua.Lds.Server/LdsServer.cs index c7514c10fd..486d2145df 100644 --- a/src/Opc.Ua.Lds.Server/LdsServer.cs +++ b/src/Opc.Ua.Lds.Server/LdsServer.cs @@ -491,25 +491,34 @@ protected virtual ServiceResult ValidateRegistration( new LocalizedText("ServerType is out of range.")); } - // Match the client cert ApplicationUri against the ServerUri. - if (secureChannelContext?.ClientChannelCertificate is { Length: > 0 } certBytes) + byte[] certBytes = secureChannelContext?.ClientChannelCertificate; + if (certBytes == null || certBytes.Length == 0) { - try - { - using var cert = Certificate.FromRawData(certBytes); - IReadOnlyList applicationUris = X509Utils.GetApplicationUrisFromCertificate(cert); - if (applicationUris.Count > 0 && - !applicationUris.Any(uri => string.Equals(uri, server.ServerUri, StringComparison.Ordinal))) - { - return new ServiceResult(StatusCodes.BadServerUriInvalid, - new LocalizedText("ServerUri does not match the certificate ApplicationUri.")); - } - } - catch (Exception ex) + return new ServiceResult( + StatusCodes.BadSecurityChecksFailed, + new LocalizedText("RegisterServer requires a SecureChannel client certificate.")); + } + + try + { + using var cert = Certificate.FromRawData(certBytes); + IReadOnlyList applicationUris = X509Utils.GetApplicationUrisFromCertificate(cert); + if (applicationUris.Count != 1 || + !string.Equals(applicationUris[0], server.ServerUri, StringComparison.Ordinal)) { - m_log?.FailedToInspectClientCertApplicationUri(ex); + return new ServiceResult( + StatusCodes.BadServerUriInvalid, + new LocalizedText( + "ServerUri must exactly match the certificate ApplicationUri.")); } } + catch (Exception ex) + { + m_log?.FailedToInspectClientCertApplicationUri(ex); + return new ServiceResult( + StatusCodes.BadCertificateInvalid, + new LocalizedText("The SecureChannel client certificate is invalid.")); + } return ServiceResult.Good; } diff --git a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs new file mode 100644 index 0000000000..fa093bd42c --- /dev/null +++ b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs @@ -0,0 +1,170 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using NUnit.Framework; +using Opc.Ua.Lds.Server; +using Opc.Ua.Security.Certificates; + +namespace Opc.Ua.Lds.Tests +{ + [TestFixture] + [Category("DiscoveryServices")] + [Parallelizable] + public sealed class RegistrationValidationTests + { + private const string ServerUri = "urn:localhost:opcfoundation.org:RegistrationValidation"; + + [Test] + public void RegistrationAcceptsOneExactCertificateApplicationUri() + { + using Certificate certificate = CreateCertificate([ServerUri]); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri)); + + Assert.That(ServiceResult.IsGood(result), Is.True); + } + + [Test] + public void RegistrationRejectsMissingClientCertificate() + { + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(null), + CreateRegisteredServer(ServerUri)); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void RegistrationRejectsMalformedClientCertificate() + { + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel([1, 2, 3, 4]), + CreateRegisteredServer(ServerUri)); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid)); + } + + [Test] + public void RegistrationRejectsCertificateWithoutApplicationUri() + { + using Certificate certificate = CreateCertificate([]); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri)); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); + } + + [Test] + public void RegistrationRejectsCertificateWithMultipleApplicationUris() + { + using Certificate certificate = CreateCertificate([ServerUri, "urn:test:other"]); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri)); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); + } + + [Test] + public void RegistrationRequiresOrdinalApplicationUriMatch() + { + using Certificate certificate = CreateCertificate([ServerUri]); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri.ToUpperInvariant())); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); + } + + private static Certificate CreateCertificate(string[] applicationUris) + { + ICertificateBuilder builder = CertificateBuilder + .Create("CN=RegistrationValidation") + .SetNotBefore(DateTime.UtcNow.AddDays(-1)) + .SetNotAfter(DateTime.UtcNow.AddDays(30)); + if (applicationUris.Length > 0) + { + builder = builder.AddExtension( + new X509SubjectAltNameExtension(applicationUris, ["localhost"])); + } + + return builder.SetRSAKeySize(2048).CreateForRSA(); + } + + private static SecureChannelContext CreateChannel(byte[] clientCertificate) + { + return new SecureChannelContext( + "registration-validation", + new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt + }, + RequestEncoding.Binary, + clientCertificate); + } + + private static RegisteredServer CreateRegisteredServer(string serverUri) + { + return new RegisteredServer + { + ServerUri = serverUri, + ProductUri = "urn:test:product", + ServerNames = [new LocalizedText("en-US", "Registration Validation")], + ServerType = ApplicationType.Server, + DiscoveryUrls = ["opc.tcp://localhost:4840"], + IsOnline = true + }; + } + + private sealed class TestLdsServer : LdsServer + { + public ServiceResult Validate( + SecureChannelContext secureChannelContext, + RegisteredServer registeredServer) + { + return ValidateRegistration(secureChannelContext, registeredServer); + } + } + } +} From 3c4882a46992bf0d88c518c1497278ae625a8f48 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 18:10:34 +0200 Subject: [PATCH 2/4] Address LDS registration review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Opc.Ua.Lds.Server/LdsServer.cs | 20 +++++++++++++++--- .../RegistrationValidationTests.cs | 21 ++++++++++++++++--- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/Opc.Ua.Lds.Server/LdsServer.cs b/src/Opc.Ua.Lds.Server/LdsServer.cs index 486d2145df..f97004c505 100644 --- a/src/Opc.Ua.Lds.Server/LdsServer.cs +++ b/src/Opc.Ua.Lds.Server/LdsServer.cs @@ -30,6 +30,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -503,8 +504,21 @@ protected virtual ServiceResult ValidateRegistration( { using var cert = Certificate.FromRawData(certBytes); IReadOnlyList applicationUris = X509Utils.GetApplicationUrisFromCertificate(cert); - if (applicationUris.Count != 1 || - !string.Equals(applicationUris[0], server.ServerUri, StringComparison.Ordinal)) + if (applicationUris.Count == 0) + { + return new ServiceResult( + StatusCodes.BadServerUriInvalid, + new LocalizedText( + "The SecureChannel client certificate has no ApplicationUri.")); + } + if (applicationUris.Count > 1) + { + return new ServiceResult( + StatusCodes.BadServerUriInvalid, + new LocalizedText( + "The SecureChannel client certificate has multiple ApplicationUris.")); + } + if (!string.Equals(applicationUris[0], server.ServerUri, StringComparison.Ordinal)) { return new ServiceResult( StatusCodes.BadServerUriInvalid, @@ -512,7 +526,7 @@ protected virtual ServiceResult ValidateRegistration( "ServerUri must exactly match the certificate ApplicationUri.")); } } - catch (Exception ex) + catch (CryptographicException ex) { m_log?.FailedToInspectClientCertApplicationUri(ex); return new ServiceResult( diff --git a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs index fa093bd42c..3aff2c9084 100644 --- a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs +++ b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs @@ -91,6 +91,19 @@ public void RegistrationRejectsCertificateWithoutApplicationUri() Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); } + [Test] + public void RegistrationRejectsSubjectAltNameWithoutApplicationUri() + { + using Certificate certificate = CreateCertificate([], includeSubjectAltName: true); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri)); + + Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); + } + [Test] public void RegistrationRejectsCertificateWithMultipleApplicationUris() { @@ -117,13 +130,15 @@ public void RegistrationRequiresOrdinalApplicationUriMatch() Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerUriInvalid)); } - private static Certificate CreateCertificate(string[] applicationUris) + private static Certificate CreateCertificate( + string[] applicationUris, + bool includeSubjectAltName = false) { ICertificateBuilder builder = CertificateBuilder .Create("CN=RegistrationValidation") .SetNotBefore(DateTime.UtcNow.AddDays(-1)) .SetNotAfter(DateTime.UtcNow.AddDays(30)); - if (applicationUris.Length > 0) + if (applicationUris.Length > 0 || includeSubjectAltName) { builder = builder.AddExtension( new X509SubjectAltNameExtension(applicationUris, ["localhost"])); @@ -132,7 +147,7 @@ private static Certificate CreateCertificate(string[] applicationUris) return builder.SetRSAKeySize(2048).CreateForRSA(); } - private static SecureChannelContext CreateChannel(byte[] clientCertificate) + private static SecureChannelContext CreateChannel(byte[]? clientCertificate) { return new SecureChannelContext( "registration-validation", From 85a488a507d551363a1fcffb9e7741f201be64b9 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 19:15:53 +0200 Subject: [PATCH 3/4] Accept matching LDS ApplicationUri among SAN entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Opc.Ua.Lds.Server/LdsServer.cs | 12 +++--------- .../RegistrationValidationTests.cs | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/Opc.Ua.Lds.Server/LdsServer.cs b/src/Opc.Ua.Lds.Server/LdsServer.cs index f97004c505..3938c076aa 100644 --- a/src/Opc.Ua.Lds.Server/LdsServer.cs +++ b/src/Opc.Ua.Lds.Server/LdsServer.cs @@ -511,19 +511,13 @@ protected virtual ServiceResult ValidateRegistration( new LocalizedText( "The SecureChannel client certificate has no ApplicationUri.")); } - if (applicationUris.Count > 1) + if (!applicationUris.Any( + uri => string.Equals(uri, server.ServerUri, StringComparison.Ordinal))) { return new ServiceResult( StatusCodes.BadServerUriInvalid, new LocalizedText( - "The SecureChannel client certificate has multiple ApplicationUris.")); - } - if (!string.Equals(applicationUris[0], server.ServerUri, StringComparison.Ordinal)) - { - return new ServiceResult( - StatusCodes.BadServerUriInvalid, - new LocalizedText( - "ServerUri must exactly match the certificate ApplicationUri.")); + "ServerUri must exactly match a certificate ApplicationUri.")); } } catch (CryptographicException ex) diff --git a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs index 3aff2c9084..1cbe82bfd8 100644 --- a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs +++ b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs @@ -105,9 +105,23 @@ public void RegistrationRejectsSubjectAltNameWithoutApplicationUri() } [Test] - public void RegistrationRejectsCertificateWithMultipleApplicationUris() + public void RegistrationAcceptsExactApplicationUriAmongMultipleUris() { - using Certificate certificate = CreateCertificate([ServerUri, "urn:test:other"]); + using Certificate certificate = CreateCertificate(["urn:test:other", ServerUri]); + using var server = new TestLdsServer(); + + ServiceResult result = server.Validate( + CreateChannel(certificate.RawData), + CreateRegisteredServer(ServerUri)); + + Assert.That(ServiceResult.IsGood(result), Is.True); + } + + [Test] + public void RegistrationRejectsMultipleApplicationUrisWithoutExactMatch() + { + using Certificate certificate = CreateCertificate( + ["urn:test:other", "urn:test:another"]); using var server = new TestLdsServer(); ServiceResult result = server.Validate( From 939199dda8654a053ca4e51db57df077fbcf98b1 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Mon, 20 Jul 2026 22:39:21 +0200 Subject: [PATCH 4/4] Cover LDS certificate URI validation --- src/Opc.Ua.Lds.Server/LdsServer.cs | 2 +- .../RegistrationValidationTests.cs | 59 +++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/Opc.Ua.Lds.Server/LdsServer.cs b/src/Opc.Ua.Lds.Server/LdsServer.cs index 3938c076aa..056591e8d4 100644 --- a/src/Opc.Ua.Lds.Server/LdsServer.cs +++ b/src/Opc.Ua.Lds.Server/LdsServer.cs @@ -492,7 +492,7 @@ protected virtual ServiceResult ValidateRegistration( new LocalizedText("ServerType is out of range.")); } - byte[] certBytes = secureChannelContext?.ClientChannelCertificate; + byte[] certBytes = secureChannelContext.ClientChannelCertificate; if (certBytes == null || certBytes.Length == 0) { return new ServiceResult( diff --git a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs index 1cbe82bfd8..c84777275f 100644 --- a/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs +++ b/tests/Opc.Ua.Lds.Tests/RegistrationValidationTests.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System; +using System.Security.Cryptography.X509Certificates; using NUnit.Framework; using Opc.Ua.Lds.Server; using Opc.Ua.Security.Certificates; @@ -37,7 +38,7 @@ namespace Opc.Ua.Lds.Tests [TestFixture] [Category("DiscoveryServices")] [Parallelizable] - public sealed class RegistrationValidationTests + public sealed class RegistrationValidationTests : LdsTestFixture { private const string ServerUri = "urn:localhost:opcfoundation.org:RegistrationValidation"; @@ -78,6 +79,37 @@ public void RegistrationRejectsMalformedClientCertificate() Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid)); } + [Test] + public void RegisterServerRejectsMalformedSubjectAltNameWithoutMutatingStore() + { + using Certificate certificate = CreateCertificateWithMalformedSubjectAltName(); + + ServiceResultException exception = Assert.ThrowsAsync(async () => + await Lds.RegisterServerAsync( + CreateChannel(certificate.RawData), + new RequestHeader(), + CreateRegisteredServer(ServerUri), + RequestLifetime.None).ConfigureAwait(false)); + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadCertificateInvalid)); + Assert.That(Lds.RegistrationStore.Snapshot(), Is.Empty); + } + + [Test] + public void RegisterServer2RejectsMissingCertificateWithoutMutatingStore() + { + ServiceResultException exception = Assert.ThrowsAsync(async () => + await Lds.RegisterServer2Async( + CreateChannel(null), + new RequestHeader(), + CreateRegisteredServer(ServerUri), + default, + RequestLifetime.None).ConfigureAwait(false)); + + Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That(Lds.RegistrationStore.Snapshot(), Is.Empty); + } + [Test] public void RegistrationRejectsCertificateWithoutApplicationUri() { @@ -148,10 +180,7 @@ private static Certificate CreateCertificate( string[] applicationUris, bool includeSubjectAltName = false) { - ICertificateBuilder builder = CertificateBuilder - .Create("CN=RegistrationValidation") - .SetNotBefore(DateTime.UtcNow.AddDays(-1)) - .SetNotAfter(DateTime.UtcNow.AddDays(30)); + ICertificateBuilder builder = CreateCertificateBuilder(); if (applicationUris.Length > 0 || includeSubjectAltName) { builder = builder.AddExtension( @@ -161,6 +190,26 @@ private static Certificate CreateCertificate( return builder.SetRSAKeySize(2048).CreateForRSA(); } + private static Certificate CreateCertificateWithMalformedSubjectAltName() + { + return CreateCertificateBuilder() + .AddExtension( + new X509Extension( + X509SubjectAltNameExtension.SubjectAltName2Oid, + [0x30, 0x01, 0x86], + critical: false)) + .SetRSAKeySize(2048) + .CreateForRSA(); + } + + private static ICertificateBuilder CreateCertificateBuilder() + { + return CertificateBuilder + .Create("CN=RegistrationValidation") + .SetNotBefore(DateTime.UtcNow.AddDays(-1)) + .SetNotAfter(DateTime.UtcNow.AddDays(30)); + } + private static SecureChannelContext CreateChannel(byte[]? clientCertificate) { return new SecureChannelContext(