Add configurable transport security profiles and bind address

Adds Security section to appsettings.json with configurable OPC UA
transport profiles (None, Basic256Sha256-Sign, Basic256Sha256-SignAndEncrypt),
certificate policy settings, and a configurable BindAddress for the
OPC UA endpoint. Defaults preserve backward compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-27 15:59:43 -04:00
parent bbd043e97b
commit 55173665b1
28 changed files with 1092 additions and 87 deletions

View File

@@ -25,6 +25,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
configuration.GetSection("MxAccess").Bind(config.MxAccess);
configuration.GetSection("GalaxyRepository").Bind(config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(config.Dashboard);
configuration.GetSection("Security").Bind(config.Security);
return config;
}
@@ -35,6 +36,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
public void OpcUa_Section_BindsCorrectly()
{
var config = LoadFromJson();
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
config.OpcUa.Port.ShouldBe(4840);
config.OpcUa.EndpointPath.ShouldBe("/LmxOpcUa");
config.OpcUa.ServerName.ShouldBe("LmxOpcUa");
@@ -117,12 +119,31 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
public void DefaultValues_AreCorrect()
{
var config = new AppConfiguration();
config.OpcUa.BindAddress.ShouldBe("0.0.0.0");
config.OpcUa.Port.ShouldBe(4840);
config.MxAccess.ClientName.ShouldBe("LmxOpcUa");
config.GalaxyRepository.ChangeDetectionIntervalSeconds.ShouldBe(30);
config.Dashboard.Enabled.ShouldBe(true);
}
/// <summary>
/// Confirms that BindAddress can be overridden to a specific hostname or IP.
/// </summary>
[Fact]
public void OpcUa_BindAddress_CanBeOverridden()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new System.Collections.Generic.KeyValuePair<string, string>("OpcUa:BindAddress", "localhost"),
})
.Build();
var config = new OpcUaConfiguration();
configuration.GetSection("OpcUa").Bind(config);
config.BindAddress.ShouldBe("localhost");
}
/// <summary>
/// Confirms that a valid configuration passes startup validation.
/// </summary>
@@ -154,5 +175,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Configuration
config.OpcUa.GalaxyName = "";
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that the Security section binds profile list from appsettings.json.
/// </summary>
[Fact]
public void Security_Section_BindsProfilesCorrectly()
{
var config = LoadFromJson();
config.Security.Profiles.ShouldContain("None");
config.Security.AutoAcceptClientCertificates.ShouldBe(true);
config.Security.MinimumCertificateKeySize.ShouldBe(2048);
}
/// <summary>
/// Confirms that a minimum key size below 2048 is rejected by the validator.
/// </summary>
[Fact]
public void Validator_InvalidMinKeySize_ReturnsFalse()
{
var config = new AppConfiguration();
config.Security.MinimumCertificateKeySize = 1024;
ConfigurationValidator.ValidateAndLog(config).ShouldBe(false);
}
/// <summary>
/// Confirms that a valid configuration with security defaults passes validation.
/// </summary>
[Fact]
public void Validator_DefaultSecurityConfig_ReturnsTrue()
{
var config = LoadFromJson();
ConfigurationValidator.ValidateAndLog(config).ShouldBe(true);
}
/// <summary>
/// Confirms that custom security profiles can be bound from in-memory configuration.
/// </summary>
[Fact]
public void Security_Section_BindsCustomProfiles()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new[]
{
new System.Collections.Generic.KeyValuePair<string, string>("Security:Profiles:0", "None"),
new System.Collections.Generic.KeyValuePair<string, string>("Security:Profiles:1", "Basic256Sha256-SignAndEncrypt"),
new System.Collections.Generic.KeyValuePair<string, string>("Security:AutoAcceptClientCertificates", "false"),
new System.Collections.Generic.KeyValuePair<string, string>("Security:MinimumCertificateKeySize", "4096"),
})
.Build();
// Clear default list before binding to match production behavior
var config = new AppConfiguration();
config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(config.Security);
config.Security.Profiles.Count.ShouldBe(2);
config.Security.Profiles.ShouldContain("None");
config.Security.Profiles.ShouldContain("Basic256Sha256-SignAndEncrypt");
config.Security.AutoAcceptClientCertificates.ShouldBe(false);
config.Security.MinimumCertificateKeySize.ShouldBe(4096);
}
}
}

View File

@@ -2,6 +2,7 @@ using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
{
@@ -108,10 +109,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// </summary>
/// <param name="mxClient">An optional fake MXAccess client to inject; otherwise a default fake is created.</param>
/// <param name="repo">An optional fake repository to inject; otherwise standard test data is used.</param>
/// <param name="security">An optional security profile configuration for the test server.</param>
/// <returns>A fixture configured to exercise the direct fake-client path.</returns>
public static OpcUaServerFixture WithFakeMxAccessClient(
FakeMxAccessClient? mxClient = null,
FakeGalaxyRepository? repo = null)
FakeGalaxyRepository? repo = null,
SecurityProfileConfiguration? security = null)
{
var client = mxClient ?? new FakeMxAccessClient();
var r = repo ?? new FakeGalaxyRepository
@@ -125,6 +128,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
.WithGalaxyRepository(r)
.WithGalaxyName("TestGalaxy");
if (security != null)
builder.WithSecurity(security);
return new OpcUaServerFixture(builder, repo: r, mxClient: client);
}

View File

@@ -49,7 +49,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
/// Connects the helper to an OPC UA endpoint exposed by the test bridge.
/// </summary>
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
public async Task ConnectAsync(string endpointUrl)
/// <param name="securityMode">The requested message security mode (default: None).</param>
/// <param name="username">Optional username for authenticated connections.</param>
/// <param name="password">Optional password for authenticated connections.</param>
public async Task ConnectAsync(string endpointUrl,
MessageSecurityMode securityMode = MessageSecurityMode.None,
string? username = null, string? password = null)
{
var config = new ApplicationConfiguration
{
@@ -87,13 +92,64 @@ namespace ZB.MOM.WW.LmxOpcUa.Tests.Helpers
await config.Validate(ApplicationType.Client);
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
EndpointDescription endpoint;
if (securityMode != MessageSecurityMode.None)
{
// Ensure client certificate exists for secure connections
var app = new ApplicationInstance
{
ApplicationName = "OpcUaTestClient",
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
await app.CheckApplicationInstanceCertificate(false, 2048);
// Discover and select endpoint matching the requested mode
endpoint = SelectEndpointByMode(endpointUrl, securityMode);
}
else
{
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
}
var endpointConfig = EndpointConfiguration.Create(config);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
UserIdentity identity = username != null
? new UserIdentity(username, password ?? "")
: new UserIdentity();
_session = await Session.Create(
config, configuredEndpoint, false,
"OpcUaTestClient", 30000, null, null);
"OpcUaTestClient", 30000, identity, null);
}
private static EndpointDescription SelectEndpointByMode(string endpointUrl, MessageSecurityMode mode)
{
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
var endpoints = client.GetEndpoints(null);
foreach (var ep in endpoints)
{
if (ep.SecurityMode == mode && ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
{
ep.EndpointUrl = endpointUrl;
return ep;
}
}
// Fall back to any matching mode
foreach (var ep in endpoints)
{
if (ep.SecurityMode == mode)
{
ep.EndpointUrl = endpointUrl;
return ep;
}
}
throw new InvalidOperationException(
$"No endpoint with security mode {mode} found on {endpointUrl}");
}
/// <summary>

View File

@@ -0,0 +1,52 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.Configuration;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Security
{
public class SecurityProfileConfigurationTests
{
[Fact]
public void DefaultConfig_HasNoneProfile()
{
var config = new SecurityProfileConfiguration();
config.Profiles.ShouldContain("None");
config.Profiles.Count.ShouldBe(1);
}
[Fact]
public void DefaultConfig_AutoAcceptTrue()
{
var config = new SecurityProfileConfiguration();
config.AutoAcceptClientCertificates.ShouldBe(true);
}
[Fact]
public void DefaultConfig_RejectSha1True()
{
var config = new SecurityProfileConfiguration();
config.RejectSHA1Certificates.ShouldBe(true);
}
[Fact]
public void DefaultConfig_MinKeySize2048()
{
var config = new SecurityProfileConfiguration();
config.MinimumCertificateKeySize.ShouldBe(2048);
}
[Fact]
public void DefaultConfig_PkiRootPathNull()
{
var config = new SecurityProfileConfiguration();
config.PkiRootPath.ShouldBeNull();
}
[Fact]
public void DefaultConfig_CertificateSubjectNull()
{
var config = new SecurityProfileConfiguration();
config.CertificateSubject.ShouldBeNull();
}
}
}

View File

@@ -0,0 +1,137 @@
using System.Collections.Generic;
using System.Linq;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Tests.Security
{
public class SecurityProfileResolverTests
{
[Fact]
public void Resolve_DefaultNone_ReturnsSingleNonePolicy()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "None" });
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
}
[Fact]
public void Resolve_SignProfile_ReturnsBasic256Sha256Sign()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-Sign" });
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.Sign);
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
[Fact]
public void Resolve_SignAndEncryptProfile_ReturnsBasic256Sha256SignAndEncrypt()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "Basic256Sha256-SignAndEncrypt" });
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
}
[Fact]
public void Resolve_MultipleProfiles_ReturnsExpectedPolicies()
{
var result = SecurityProfileResolver.Resolve(new List<string>
{
"None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"
});
result.Count.ShouldBe(3);
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.SignAndEncrypt);
}
[Fact]
public void Resolve_DuplicateProfiles_Deduplicated()
{
var result = SecurityProfileResolver.Resolve(new List<string>
{
"None", "None", "Basic256Sha256-Sign", "Basic256Sha256-Sign"
});
result.Count.ShouldBe(2);
}
[Fact]
public void Resolve_UnknownProfile_SkippedWithWarning()
{
var result = SecurityProfileResolver.Resolve(new List<string>
{
"None", "SomeUnknownProfile"
});
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
[Fact]
public void Resolve_EmptyList_FallsBackToNone()
{
var result = SecurityProfileResolver.Resolve(new List<string>());
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
result[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
}
[Fact]
public void Resolve_NullList_FallsBackToNone()
{
var result = SecurityProfileResolver.Resolve(null!);
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
[Fact]
public void Resolve_AllUnknownProfiles_FallsBackToNone()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "Bogus", "AlsoBogus" });
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
[Fact]
public void Resolve_CaseInsensitive()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "none", "BASIC256SHA256-SIGN" });
result.Count.ShouldBe(2);
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.None);
result.ShouldContain(p => p.SecurityMode == MessageSecurityMode.Sign);
}
[Fact]
public void Resolve_WhitespaceEntries_Skipped()
{
var result = SecurityProfileResolver.Resolve(new List<string> { "", " ", "None" });
result.Count.ShouldBe(1);
result[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
}
[Fact]
public void ValidProfileNames_ContainsExpectedEntries()
{
var names = SecurityProfileResolver.ValidProfileNames;
names.ShouldContain("None");
names.ShouldContain("Basic256Sha256-Sign");
names.ShouldContain("Basic256Sha256-SignAndEncrypt");
names.Count.ShouldBe(3);
}
}
}