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

@@ -34,5 +34,10 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// Gets or sets the authentication and role-based access control settings.
/// </summary>
public AuthenticationConfiguration Authentication { get; set; } = new AuthenticationConfiguration();
/// <summary>
/// Gets or sets the transport security settings that control which OPC UA security profiles are exposed.
/// </summary>
public SecurityProfileConfiguration Security { get; set; } = new SecurityProfileConfiguration();
}
}

View File

@@ -1,4 +1,6 @@
using System.Linq;
using Serilog;
using ZB.MOM.WW.LmxOpcUa.Host.OpcUa;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
@@ -21,8 +23,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Information("=== Effective Configuration ===");
// OPC UA
Log.Information("OpcUa.Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
Log.Information("OpcUa.BindAddress={BindAddress}, Port={Port}, EndpointPath={EndpointPath}, ServerName={ServerName}, GalaxyName={GalaxyName}",
config.OpcUa.BindAddress, config.OpcUa.Port, config.OpcUa.EndpointPath, config.OpcUa.ServerName, config.OpcUa.GalaxyName);
Log.Information("OpcUa.MaxSessions={MaxSessions}, SessionTimeoutMinutes={SessionTimeout}",
config.OpcUa.MaxSessions, config.OpcUa.SessionTimeoutMinutes);
@@ -67,6 +69,41 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
Log.Information("Dashboard.Enabled={Enabled}, Port={Port}, RefreshInterval={Refresh}s",
config.Dashboard.Enabled, config.Dashboard.Port, config.Dashboard.RefreshIntervalSeconds);
// Security
Log.Information("Security.Profiles=[{Profiles}], AutoAcceptClientCertificates={AutoAccept}, RejectSHA1={RejectSHA1}, MinKeySize={MinKeySize}",
string.Join(", ", config.Security.Profiles), config.Security.AutoAcceptClientCertificates,
config.Security.RejectSHA1Certificates, config.Security.MinimumCertificateKeySize);
if (config.Security.PkiRootPath != null)
Log.Information("Security.PkiRootPath={PkiRootPath}", config.Security.PkiRootPath);
if (config.Security.CertificateSubject != null)
Log.Information("Security.CertificateSubject={CertificateSubject}", config.Security.CertificateSubject);
var unknownProfiles = config.Security.Profiles
.Where(p => !SecurityProfileResolver.ValidProfileNames.Contains(p, System.StringComparer.OrdinalIgnoreCase))
.ToList();
if (unknownProfiles.Count > 0)
{
Log.Warning("Unknown security profile(s): {Profiles}. Valid values: {ValidProfiles}",
string.Join(", ", unknownProfiles), string.Join(", ", SecurityProfileResolver.ValidProfileNames));
}
if (config.Security.MinimumCertificateKeySize < 2048)
{
Log.Error("Security.MinimumCertificateKeySize must be at least 2048");
valid = false;
}
if (config.Security.AutoAcceptClientCertificates)
{
Log.Warning("Security.AutoAcceptClientCertificates is enabled — client certificate trust is not enforced. Set to false in production");
}
if (config.Security.Profiles.Count == 1 && config.Security.Profiles[0].Equals("None", System.StringComparison.OrdinalIgnoreCase))
{
Log.Warning("Only the 'None' security profile is configured — transport security is disabled");
}
Log.Information("=== Configuration {Status} ===", valid ? "Valid" : "INVALID");
return valid;
}

View File

@@ -5,6 +5,12 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
/// </summary>
public class OpcUaConfiguration
{
/// <summary>
/// Gets or sets the IP address or hostname the OPC UA server binds to.
/// Defaults to <c>0.0.0.0</c> (all interfaces). Set to a specific IP or hostname to restrict listening.
/// </summary>
public string BindAddress { get; set; } = "0.0.0.0";
/// <summary>
/// Gets or sets the TCP port on which the OPC UA server listens for client sessions.
/// </summary>

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxOpcUa.Host.Configuration
{
/// <summary>
/// Transport security settings that control which OPC UA security profiles the server exposes and how client certificates are handled.
/// </summary>
public class SecurityProfileConfiguration
{
/// <summary>
/// Gets or sets the list of security profile names to expose as server endpoints.
/// Valid values: "None", "Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt".
/// Defaults to ["None"] for backward compatibility.
/// </summary>
public List<string> Profiles { get; set; } = new List<string> { "None" };
/// <summary>
/// Gets or sets a value indicating whether the server automatically accepts client certificates
/// that are not in the trusted store. Should be <see langword="false"/> in production.
/// </summary>
public bool AutoAcceptClientCertificates { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether client certificates signed with SHA-1 are rejected.
/// </summary>
public bool RejectSHA1Certificates { get; set; } = true;
/// <summary>
/// Gets or sets the minimum RSA key size required for client certificates.
/// </summary>
public int MinimumCertificateKeySize { get; set; } = 2048;
/// <summary>
/// Gets or sets an optional override for the PKI root directory.
/// When <see langword="null"/>, defaults to <c>%LOCALAPPDATA%\OPC Foundation\pki</c>.
/// </summary>
public string? PkiRootPath { get; set; }
/// <summary>
/// Gets or sets an optional override for the server certificate subject name.
/// When <see langword="null"/>, defaults to <c>CN={ServerName}, O=ZB MOM, DC=localhost</c>.
/// </summary>
public string? CertificateSubject { get; set; }
}
}

View File

@@ -24,6 +24,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
private readonly HistorianDataSource? _historianDataSource;
private readonly AuthenticationConfiguration _authConfig;
private readonly IUserAuthenticationProvider? _authProvider;
private readonly SecurityProfileConfiguration _securityConfig;
private ApplicationInstance? _application;
private LmxOpcUaServer? _server;
@@ -52,7 +53,8 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
public OpcUaServerHost(OpcUaConfiguration config, IMxAccessClient mxAccessClient, PerformanceMetrics metrics,
HistorianDataSource? historianDataSource = null,
AuthenticationConfiguration? authConfig = null,
IUserAuthenticationProvider? authProvider = null)
IUserAuthenticationProvider? authProvider = null,
SecurityProfileConfiguration? securityConfig = null)
{
_config = config;
_mxAccessClient = mxAccessClient;
@@ -60,6 +62,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
_historianDataSource = historianDataSource;
_authConfig = authConfig ?? new AuthenticationConfiguration();
_authProvider = authProvider;
_securityConfig = securityConfig ?? new SecurityProfileConfiguration();
}
/// <summary>
@@ -69,63 +72,66 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
var namespaceUri = $"urn:{_config.GalaxyName}:LmxOpcUa";
// Resolve configured security profiles
var securityPolicies = SecurityProfileResolver.Resolve(_securityConfig.Profiles);
foreach (var sp in securityPolicies)
{
Log.Information("Security profile active: {PolicyUri} / {Mode}", sp.SecurityPolicyUri, sp.SecurityMode);
}
// Build PKI paths
var pkiRoot = _securityConfig.PkiRootPath ?? System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki");
var certSubject = _securityConfig.CertificateSubject ?? $"CN={_config.ServerName}, O=ZB MOM, DC=localhost";
var serverConfig = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://{_config.BindAddress}:{_config.Port}{_config.EndpointPath}" },
MaxSessionCount = _config.MaxSessions,
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
MinSessionTimeout = 10000,
UserTokenPolicies = BuildUserTokenPolicies()
};
foreach (var policy in securityPolicies)
serverConfig.SecurityPolicies.Add(policy);
var secConfig = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(pkiRoot, "own"),
SubjectName = certSubject
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(pkiRoot, "issuer")
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(pkiRoot, "trusted")
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(pkiRoot, "rejected")
},
AutoAcceptUntrustedCertificates = _securityConfig.AutoAcceptClientCertificates,
RejectSHA1SignedCertificates = _securityConfig.RejectSHA1Certificates,
MinimumCertificateKeySize = (ushort)_securityConfig.MinimumCertificateKeySize
};
var appConfig = new ApplicationConfiguration
{
ApplicationName = _config.ServerName,
ApplicationUri = namespaceUri,
ApplicationType = ApplicationType.Server,
ProductUri = namespaceUri,
ServerConfiguration = new ServerConfiguration
{
BaseAddresses = { $"opc.tcp://0.0.0.0:{_config.Port}{_config.EndpointPath}" },
MaxSessionCount = _config.MaxSessions,
MaxSessionTimeout = _config.SessionTimeoutMinutes * 60 * 1000, // ms
MinSessionTimeout = 10000,
SecurityPolicies =
{
new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
}
},
UserTokenPolicies = BuildUserTokenPolicies()
},
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki", "own"),
SubjectName = $"CN={_config.ServerName}, O=ZB MOM, DC=localhost"
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki", "issuer")
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki", "trusted")
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OPC Foundation", "pki", "rejected")
},
AutoAcceptUntrustedCertificates = true
},
ServerConfiguration = serverConfig,
SecurityConfiguration = secConfig,
TransportQuotas = new TransportQuotas
{
@@ -148,6 +154,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
await appConfig.Validate(ApplicationType.Server);
// Hook certificate validation logging
appConfig.CertificateValidator.CertificateValidation += OnCertificateValidation;
_application = new ApplicationInstance
{
ApplicationName = _config.ServerName,
@@ -156,19 +165,34 @@ namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
};
// Check/create application certificate
bool certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
var minKeySize = (ushort)_securityConfig.MinimumCertificateKeySize;
bool certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
if (!certOk)
{
Log.Warning("Application certificate check failed, attempting to create...");
certOk = await _application.CheckApplicationInstanceCertificate(false, 2048);
certOk = await _application.CheckApplicationInstanceCertificate(false, minKeySize);
}
_server = new LmxOpcUaServer(_config.GalaxyName, _mxAccessClient, _metrics, _historianDataSource,
_config.AlarmTrackingEnabled, _authConfig, _authProvider);
await _application.Start(_server);
Log.Information("OPC UA server started on opc.tcp://localhost:{Port}{EndpointPath} (namespace={Namespace})",
_config.Port, _config.EndpointPath, namespaceUri);
Log.Information("OPC UA server started on opc.tcp://{BindAddress}:{Port}{EndpointPath} (namespace={Namespace})",
_config.BindAddress, _config.Port, _config.EndpointPath, namespaceUri);
}
private void OnCertificateValidation(CertificateValidator sender, CertificateValidationEventArgs e)
{
if (_securityConfig.AutoAcceptClientCertificates)
{
e.Accept = true;
Log.Debug("Client certificate auto-accepted: {Subject}", e.Certificate?.Subject);
}
else
{
Log.Warning("Client certificate validation: {Error} for {Subject} — Accepted={Accepted}",
e.Error?.StatusCode, e.Certificate?.Subject, e.Accept);
}
}
/// <summary>

View File

@@ -0,0 +1,86 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Opc.Ua;
using Opc.Ua.Server;
using Serilog;
namespace ZB.MOM.WW.LmxOpcUa.Host.OpcUa
{
/// <summary>
/// Maps configured security profile names to OPC UA <see cref="ServerSecurityPolicy"/> instances.
/// </summary>
public static class SecurityProfileResolver
{
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(SecurityProfileResolver));
private static readonly Dictionary<string, ServerSecurityPolicy> KnownProfiles =
new Dictionary<string, ServerSecurityPolicy>(StringComparer.OrdinalIgnoreCase)
{
["None"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
},
["Basic256Sha256-Sign"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.Sign,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
},
["Basic256Sha256-SignAndEncrypt"] = new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
}
};
/// <summary>
/// Resolves the configured profile names to <see cref="ServerSecurityPolicy"/> entries.
/// Unknown names are skipped with a warning. An empty or fully-invalid list falls back to <c>None</c>.
/// </summary>
/// <param name="profileNames">The profile names from configuration.</param>
/// <returns>A deduplicated list of server security policies.</returns>
public static List<ServerSecurityPolicy> Resolve(IReadOnlyCollection<string> profileNames)
{
var resolved = new List<ServerSecurityPolicy>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in profileNames ?? Array.Empty<string>())
{
if (string.IsNullOrWhiteSpace(name))
continue;
var trimmed = name.Trim();
if (!seen.Add(trimmed))
{
Log.Debug("Skipping duplicate security profile: {Profile}", trimmed);
continue;
}
if (KnownProfiles.TryGetValue(trimmed, out var policy))
{
resolved.Add(policy);
}
else
{
Log.Warning("Unknown security profile '{Profile}' — skipping. Valid profiles: {ValidProfiles}",
trimmed, string.Join(", ", KnownProfiles.Keys));
}
}
if (resolved.Count == 0)
{
Log.Warning("No valid security profiles configured — falling back to None");
resolved.Add(KnownProfiles["None"]);
}
return resolved;
}
/// <summary>
/// Gets the list of valid profile names for validation and documentation.
/// </summary>
public static IReadOnlyCollection<string> ValidProfileNames => KnownProfiles.Keys.ToList().AsReadOnly();
}
}

View File

@@ -56,6 +56,9 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
configuration.GetSection("Historian").Bind(_config.Historian);
configuration.GetSection("Authentication").Bind(_config.Authentication);
// Clear the default Profiles list before binding so JSON values replace rather than append
_config.Security.Profiles.Clear();
configuration.GetSection("Security").Bind(_config.Security);
_mxProxy = new MxProxyAdapter();
_galaxyRepository = new GalaxyRepositoryService(_config.GalaxyRepository);
@@ -161,7 +164,7 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
? new Domain.ConfigUserAuthenticationProvider(_config.Authentication.Users)
: (Domain.IUserAuthenticationProvider?)null;
_serverHost = new OpcUaServerHost(_config.OpcUa, effectiveMxClient, _metrics, historianDataSource,
_config.Authentication, authProvider);
_config.Authentication, authProvider, _config.Security);
// Step 9-10: Query hierarchy, start server, build address space
DateTime? initialDeployTime = null;

View File

@@ -122,6 +122,17 @@ namespace ZB.MOM.WW.LmxOpcUa.Host
return this;
}
/// <summary>
/// Sets the security profile configuration for the test host.
/// </summary>
/// <param name="security">The security profile configuration to inject.</param>
/// <returns>The current builder so additional overrides can be chained.</returns>
public OpcUaServiceBuilder WithSecurity(SecurityProfileConfiguration security)
{
_config.Security = security;
return this;
}
/// <summary>
/// Effectively disables Galaxy change detection by pushing the polling interval beyond realistic test durations.
/// </summary>

View File

@@ -1,5 +1,6 @@
{
"OpcUa": {
"BindAddress": "0.0.0.0",
"Port": 4840,
"EndpointPath": "/LmxOpcUa",
"ServerName": "LmxOpcUa",
@@ -36,6 +37,14 @@
"AnonymousCanWrite": true,
"Users": []
},
"Security": {
"Profiles": ["None"],
"AutoAcceptClientCertificates": true,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048,
"PkiRootPath": null,
"CertificateSubject": null
},
"Historian": {
"Enabled": false,
"ConnectionString": "Server=localhost;Database=Runtime;Integrated Security=true;",