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

@@ -77,6 +77,10 @@ dotnet test --filter "FullyQualifiedName~MyTestClass.MyMethod" # single test
- MXAccess requires a deployed ArchestrA Platform on the machine running the server
- COM apartment: MXAccess objects must live on an STA thread with a message pump
## Transport Security
The server supports configurable OPC UA transport security via the `Security` section in `appsettings.json`. Phase 1 profiles: `None` (default), `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Security profiles are resolved by `SecurityProfileResolver` at startup. The server certificate is always created even for `None`-only deployments because `UserName` token encryption depends on it. See `docs/security.md` for the full guide.
## Library Preferences
- **Logging**: Serilog with rolling daily file sink

View File

@@ -52,7 +52,7 @@ dotnet build ZB.MOM.WW.LmxOpcUa.slnx
dotnet run --project src/ZB.MOM.WW.LmxOpcUa.Host
```
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with SecurityPolicy None.
The server starts on `opc.tcp://localhost:4840/LmxOpcUa` with the `None` security profile by default. Configure `Security.Profiles` in `appsettings.json` to enable `Basic256Sha256-Sign` or `Basic256Sha256-SignAndEncrypt` for transport security. See [Security Guide](docs/security.md).
### Install as Windows service
@@ -140,6 +140,7 @@ gr/ Galaxy repository docs, SQL queries, schema
| [Status Dashboard](docs/StatusDashboard.md) | HTTP server, health checks, metrics reporting |
| [Service Hosting](docs/ServiceHosting.md) | TopShelf, startup/shutdown sequence, error handling |
| [CLI Tool](docs/CliTool.md) | Connect, browse, read, write, subscribe, historyread, alarms commands |
| [Security](docs/security.md) | Transport security profiles, certificate trust, production hardening |
## Related Documentation

View File

@@ -37,6 +37,31 @@ Example:
dotnet run -- write -u opc.tcp://localhost:4840 -n "ns=2;s=MyNode" -v 42 -U operator -P op123
```
## Transport Security Options
All commands accept the `-S` / `--security` flag to select the transport security mode:
| Flag | Values | Description |
|------|--------|-------------|
| `-S` / `--security` | `none`, `sign`, `encrypt` | Transport security mode (default: `none`) |
When `sign` or `encrypt` is specified, the CLI tool:
1. Ensures a client application certificate exists (auto-created if missing)
2. Discovers server endpoints and selects one matching the requested `MessageSecurityMode`
3. Prefers `Basic256Sha256` when multiple matching endpoints exist
4. Fails with a clear error if no matching endpoint is found
Examples:
```bash
# Connect with encrypted transport
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt
# Browse with signed transport and credentials
dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S sign -U admin -P secret -r -d 2
```
## Commands
### connect

View File

@@ -2,7 +2,7 @@
## Overview
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates five typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, and `Historian`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
The service loads configuration from `appsettings.json` at startup using the Microsoft.Extensions.Configuration stack. `AppConfiguration` is the root holder class that aggregates typed sections: `OpcUa`, `MxAccess`, `GalaxyRepository`, `Dashboard`, `Historian`, `Authentication`, and `Security`. Each section binds to a dedicated POCO class with sensible defaults, so the service runs with zero configuration on a standard deployment.
## Config Binding Pattern
@@ -22,6 +22,7 @@ configuration.GetSection("GalaxyRepository").Bind(_config.GalaxyRepository);
configuration.GetSection("Dashboard").Bind(_config.Dashboard);
configuration.GetSection("Historian").Bind(_config.Historian);
configuration.GetSection("Authentication").Bind(_config.Authentication);
configuration.GetSection("Security").Bind(_config.Security);
```
This pattern uses `IConfiguration.GetSection().Bind()` rather than `IOptions<T>` because the service targets .NET Framework 4.8, where the full dependency injection container is not used.
@@ -46,6 +47,7 @@ Controls the OPC UA server endpoint and session limits. Defined in `OpcUaConfigu
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `BindAddress` | `string` | `"0.0.0.0"` | IP address or hostname the server binds to. Use `0.0.0.0` for all interfaces, `localhost` for local-only, or a specific IP |
| `Port` | `int` | `4840` | TCP port the OPC UA server listens on |
| `EndpointPath` | `string` | `"/LmxOpcUa"` | Path appended to the host URI |
| `ServerName` | `string` | `"LmxOpcUa"` | Server name presented to OPC UA clients |
@@ -130,6 +132,30 @@ Example configuration:
}
```
### Security
Controls OPC UA transport security profiles and certificate handling. Defined in `SecurityProfileConfiguration`. See [Security Guide](security.md) for detailed usage.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Profiles` | `List<string>` | `["None"]` | Security profiles to expose. Valid: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt` |
| `AutoAcceptClientCertificates` | `bool` | `true` | Auto-accept untrusted client certificates. Set to `false` in production |
| `RejectSHA1Certificates` | `bool` | `true` | Reject client certificates signed with SHA-1 |
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size for client certificates |
| `PkiRootPath` | `string?` | `null` | Override for PKI root directory. Defaults to `%LOCALAPPDATA%\OPC Foundation\pki` |
| `CertificateSubject` | `string?` | `null` | Override for server certificate subject. Defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` |
Example — production deployment with encrypted transport:
```json
"Security": {
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
"AutoAcceptClientCertificates": false,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048
}
```
## Feature Flags
Three boolean properties act as feature flags that control optional subsystems:
@@ -146,6 +172,10 @@ Three boolean properties act as feature flags that control optional subsystems:
- `OpcUa.GalaxyName` must not be empty
- `MxAccess.ClientName` must not be empty
- `GalaxyRepository.ConnectionString` must not be empty
- `Security.MinimumCertificateKeySize` must be at least 2048
- Unknown security profile names are logged as warnings
- `AutoAcceptClientCertificates = true` emits a warning
- Only-`None` profile configuration emits a warning
If validation fails, the service throws `InvalidOperationException` and does not start.
@@ -169,6 +199,7 @@ Integration tests use this constructor to inject substitute implementations of `
```json
{
"OpcUa": {
"BindAddress": "0.0.0.0",
"Port": 4840,
"EndpointPath": "/LmxOpcUa",
"ServerName": "LmxOpcUa",
@@ -210,6 +241,14 @@ Integration tests use this constructor to inject substitute implementations of `
"AllowAnonymous": true,
"AnonymousCanWrite": true,
"Users": []
},
"Security": {
"Profiles": ["None"],
"AutoAcceptClientCertificates": true,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048,
"PkiRootPath": null,
"CertificateSubject": null
}
}
```

View File

@@ -8,6 +8,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
| Property | Default | Description |
|----------|---------|-------------|
| `BindAddress` | `0.0.0.0` | IP address or hostname the server binds to |
| `Port` | `4840` | TCP port the server listens on |
| `EndpointPath` | `/LmxOpcUa` | URI path appended to the base address |
| `ServerName` | `LmxOpcUa` | Application name presented to clients |
@@ -16,7 +17,7 @@ The OPC UA server component hosts the Galaxy-backed namespace on a configurable
| `SessionTimeoutMinutes` | `30` | Idle session timeout |
| `AlarmTrackingEnabled` | `false` | Enables `AlarmConditionState` nodes for alarm attributes |
The resulting endpoint URL is `opc.tcp://0.0.0.0:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
The resulting endpoint URL is `opc.tcp://{BindAddress}:{Port}{EndpointPath}`, e.g., `opc.tcp://0.0.0.0:4840/LmxOpcUa`.
The namespace URI follows the pattern `urn:{GalaxyName}:LmxOpcUa` and serves as both the `ApplicationUri` and `ProductUri`.
@@ -31,19 +32,21 @@ The configuration covers:
- **TransportQuotas** -- 4 MB max message/string/byte-string size, 120-second operation timeout, 1-hour security token lifetime
- **TraceConfiguration** -- OPC Foundation SDK tracing is disabled (output path `null`, trace masks `0`); all logging goes through Serilog instead
## Security Policy
## Security Profiles
The server runs with `MessageSecurityMode.None` and `SecurityPolicies.None`:
The server supports configurable transport security profiles controlled by the `Security` section in `appsettings.json`. The default configuration exposes only `MessageSecurityMode.None` for backward compatibility.
```csharp
SecurityPolicies = { new ServerSecurityPolicy
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None
} }
```
Supported Phase 1 profiles:
This is intentional for plant-floor deployments where the server sits on an isolated OT network. Galaxy-level security classification controls write access per attribute rather than at the transport layer.
| Profile Name | SecurityPolicy URI | MessageSecurityMode |
|---|---|---|
| `None` | `SecurityPolicy#None` | `None` |
| `Basic256Sha256-Sign` | `SecurityPolicy#Basic256Sha256` | `Sign` |
| `Basic256Sha256-SignAndEncrypt` | `SecurityPolicy#Basic256Sha256` | `SignAndEncrypt` |
`SecurityProfileResolver` maps configured profile names to `ServerSecurityPolicy` instances at startup. Unknown names are skipped with a warning, and an empty or invalid list falls back to `None`.
For production deployments, configure `["Basic256Sha256-SignAndEncrypt"]` or `["None", "Basic256Sha256-SignAndEncrypt"]` and set `AutoAcceptClientCertificates` to `false`. See the [Security Guide](security.md) for hardening details.
### User token policies
@@ -62,7 +65,7 @@ On successful validation, the session identity is set to a `RoleBasedIdentity` t
## Certificate handling
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, 2048)` to locate or create a 2048-bit self-signed certificate. The certificate subject follows the format `CN={ServerName}, O=ZB MOM, DC=localhost`. Certificate stores use the directory-based store type under `%LOCALAPPDATA%\OPC Foundation\pki\`:
On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertificate(false, minKeySize)` to locate or create a self-signed certificate meeting the configured minimum key size (default 2048). The certificate subject defaults to `CN={ServerName}, O=ZB MOM, DC=localhost` but can be overridden via `Security.CertificateSubject`. Certificate stores use the directory-based store type under the configured `Security.PkiRootPath` (default `%LOCALAPPDATA%\OPC Foundation\pki\`):
| Store | Path suffix |
|-------|-------------|
@@ -71,7 +74,7 @@ On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertific
| Trusted peers | `pki/trusted` |
| Rejected | `pki/rejected` |
`AutoAcceptUntrustedCertificates` is set to `true` so the server does not reject client certificates.
`AutoAcceptUntrustedCertificates` is controlled by `Security.AutoAcceptClientCertificates` (default `true`). Set to `false` in production to enforce client certificate trust. When `RejectSHA1Certificates` is `true` (default), client certificates signed with SHA-1 are rejected. Certificate validation events are logged for visibility into accepted and rejected client connections.
## Server class hierarchy
@@ -101,4 +104,6 @@ On startup, `OpcUaServerHost.StartAsync` calls `CheckApplicationInstanceCertific
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/OpcUaServerHost.cs` -- Application lifecycle and programmatic configuration
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/LmxOpcUaServer.cs` -- StandardServer subclass and node manager creation
- `src/ZB.MOM.WW.LmxOpcUa.Host/OpcUa/SecurityProfileResolver.cs` -- Profile-name to ServerSecurityPolicy mapping
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/OpcUaConfiguration.cs` -- Configuration POCO
- `src/ZB.MOM.WW.LmxOpcUa.Host/Configuration/SecurityProfileConfiguration.cs` -- Security configuration POCO

259
docs/security.md Normal file
View File

@@ -0,0 +1,259 @@
# Transport Security
## Overview
The LmxOpcUa server supports configurable transport security profiles that control how data is protected on the wire between OPC UA clients and the server.
There are two distinct layers of security in OPC UA:
- **Transport security** -- secures the communication channel itself using TLS-style certificate exchange, message signing, and encryption. This is what the `Security` configuration section controls.
- **UserName token encryption** -- protects user credentials (username/password) sent during session activation. The OPC UA stack encrypts UserName tokens using the server's application certificate regardless of the transport security mode. This means UserName authentication works on `None` endpoints too — the credentials themselves are always encrypted. However, a secure transport profile adds protection against message-level tampering and eavesdropping of data payloads.
## Supported Security Profiles
The server supports three transport security profiles in Phase 1:
| Profile Name | Security Policy | Message Security Mode | Description |
|-----------------------------------|---------------------|-----------------------|--------------------------------------------------|
| `None` | None | None | No signing or encryption. Suitable for development and isolated networks only. |
| `Basic256Sha256-Sign` | Basic256Sha256 | Sign | Messages are signed but not encrypted. Protects against tampering but data is visible on the wire. |
| `Basic256Sha256-SignAndEncrypt` | Basic256Sha256 | SignAndEncrypt | Messages are both signed and encrypted. Full protection against tampering and eavesdropping. |
Multiple profiles can be enabled simultaneously. The server exposes a separate endpoint for each configured profile, and clients select the one they prefer during connection.
If no valid profiles are configured (or all names are unrecognized), the server falls back to `None` with a warning in the log.
## Configuration
Transport security is configured in the `Security` section of `appsettings.json`:
```json
{
"Security": {
"Profiles": ["None"],
"AutoAcceptClientCertificates": true,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048,
"PkiRootPath": null,
"CertificateSubject": null
}
}
```
### Properties
| Property | Type | Default | Description |
|--------------------------------|------------|--------------------------------------------------|-------------|
| `Profiles` | `string[]` | `["None"]` | List of security profile names to expose as server endpoints. Valid values: `None`, `Basic256Sha256-Sign`, `Basic256Sha256-SignAndEncrypt`. Profile names are case-insensitive. Duplicates are ignored. |
| `AutoAcceptClientCertificates` | `bool` | `true` | When `true`, the server automatically trusts client certificates that are not already in the trusted store. Set to `false` in production for explicit trust management. |
| `RejectSHA1Certificates` | `bool` | `true` | When `true`, client certificates signed with SHA-1 are rejected. SHA-1 is considered cryptographically weak. |
| `MinimumCertificateKeySize` | `int` | `2048` | Minimum RSA key size (in bits) required for client certificates. Certificates with shorter keys are rejected. |
| `PkiRootPath` | `string?` | `null` (defaults to `%LOCALAPPDATA%\OPC Foundation\pki`) | Override for the PKI root directory where certificates are stored. When `null`, uses the OPC Foundation default location. |
| `CertificateSubject` | `string?` | `null` (defaults to `CN={ServerName}, O=ZB MOM, DC=localhost`) | Override for the server certificate subject name. When `null`, the subject is derived from the configured `ServerName`. |
### Example: Development (no security)
```json
{
"Security": {
"Profiles": ["None"],
"AutoAcceptClientCertificates": true
}
}
```
### Example: Production (encrypted only)
```json
{
"Security": {
"Profiles": ["Basic256Sha256-SignAndEncrypt"],
"AutoAcceptClientCertificates": false,
"RejectSHA1Certificates": true,
"MinimumCertificateKeySize": 2048
}
}
```
### Example: Mixed (sign and encrypt endpoints, no plaintext)
```json
{
"Security": {
"Profiles": ["Basic256Sha256-Sign", "Basic256Sha256-SignAndEncrypt"],
"AutoAcceptClientCertificates": false
}
}
```
## PKI Directory Layout
The server stores certificates in a directory-based PKI store. The default root is:
```
%LOCALAPPDATA%\OPC Foundation\pki\
```
This can be overridden with the `PkiRootPath` setting. The directory structure is:
```
pki/
own/ Server's own application certificate and private key
issuer/ CA certificates that issued trusted client certificates
trusted/ Explicitly trusted client (peer) certificates
rejected/ Certificates that were presented but not trusted
```
### Certificate Trust Flow
When a client connects using a secure profile (`Sign` or `SignAndEncrypt`), the following trust evaluation occurs:
1. The client presents its application certificate during the secure channel handshake.
2. The server checks whether the certificate exists in the `trusted/` store.
3. If found, the connection proceeds (subject to key size and SHA-1 checks).
4. If not found and `AutoAcceptClientCertificates` is `true`, the certificate is automatically copied to `trusted/` and the connection proceeds.
5. If not found and `AutoAcceptClientCertificates` is `false`, the certificate is copied to `rejected/` and the connection is refused.
6. Regardless of trust status, the certificate must meet the `MinimumCertificateKeySize` requirement and pass the SHA-1 check (if `RejectSHA1Certificates` is `true`).
On first startup with a secure profile, the server automatically generates a self-signed application certificate in the `own/` directory if one does not already exist.
## Production Hardening
The default settings prioritize ease of development. Before deploying to production, apply the following changes:
### 1. Disable automatic certificate acceptance
Set `AutoAcceptClientCertificates` to `false` so that only explicitly trusted client certificates are accepted:
```json
{
"Security": {
"AutoAcceptClientCertificates": false
}
}
```
After changing this setting, you must manually copy each client's application certificate (the `.der` file) into the `trusted/` directory.
### 2. Remove the None profile
Remove `None` from the `Profiles` list to prevent unencrypted connections:
```json
{
"Security": {
"Profiles": ["Basic256Sha256-SignAndEncrypt"]
}
}
```
### 3. Configure named users
Disable anonymous access and define named users in the `Authentication` section. Use `AnonymousCanWrite` to control whether anonymous clients (if still allowed) can write:
```json
{
"Authentication": {
"AllowAnonymous": false,
"AnonymousCanWrite": false,
"Users": [
{ "Username": "operator", "Password": "secure-password" },
{ "Username": "viewer", "Password": "read-only-password" }
]
}
}
```
While UserName tokens are always encrypted by the OPC UA stack (using the server certificate), enabling a secure transport profile adds protection against message-level tampering and data eavesdropping.
### 4. Review the rejected certificate store
Periodically inspect the `rejected/` directory. Certificates that appear here were presented by clients but were not trusted. If you recognize a legitimate client certificate, move it to the `trusted/` directory to grant access.
## CLI Examples
The `tools/opcuacli-dotnet` CLI tool supports the `-S` (or `--security`) flag to select the transport security mode when connecting. Valid values are `none`, `sign`, and `encrypt`.
### Connect with no security
```bash
cd tools/opcuacli-dotnet
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
```
### Connect with signing
```bash
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S sign
```
### Connect with signing and encryption
```bash
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt
```
### Browse with encryption and authentication
```bash
dotnet run -- browse -u opc.tcp://localhost:4840/LmxOpcUa -S encrypt -U operator -P secure-password -r -d 3
```
### Read a node with signing
```bash
dotnet run -- read -u opc.tcp://localhost:4840/LmxOpcUa -S sign -n "ns=2;s=TestMachine_001/Speed"
```
The CLI tool auto-generates its own client certificate on first use (stored under `%LOCALAPPDATA%\OpcUaCli\pki\own\`). When connecting to a server with `AutoAcceptClientCertificates` set to `false`, you must copy the CLI tool's certificate into the server's `trusted/` directory before the connection will succeed.
## Troubleshooting
### Certificate trust failure
**Symptom:** The client receives a `BadSecurityChecksFailed` or `BadCertificateUntrusted` error when connecting.
**Cause:** The server does not trust the client's certificate (or vice versa), and `AutoAcceptClientCertificates` is `false`.
**Resolution:**
1. Check the server's `rejected/` directory for the client's certificate file.
2. Copy the `.der` file from `rejected/` to `trusted/`.
3. Retry the connection.
4. If the server's own certificate is not trusted by the client, copy the server's certificate from `pki/own/certs/` to the client's trusted store.
### Endpoint mismatch
**Symptom:** The client receives a `BadSecurityModeRejected` or `BadSecurityPolicyRejected` error, or reports "No endpoint found with security mode...".
**Cause:** The client is requesting a security mode that the server does not expose. For example, the client requests `SignAndEncrypt` but the server only has `None` configured.
**Resolution:**
1. Verify the server's configured `Profiles` in `appsettings.json`.
2. Ensure the profile matching the client's requested mode is listed (e.g., add `Basic256Sha256-SignAndEncrypt` for encrypted connections).
3. Restart the server after changing the configuration.
4. Use the CLI tool to verify available endpoints:
```bash
dotnet run -- connect -u opc.tcp://localhost:4840/LmxOpcUa -S none
```
The output displays the security mode and policy of the connected endpoint.
### Server certificate not generated
**Symptom:** The server logs a warning about application certificate check failure on startup.
**Cause:** The `pki/own/` directory may not be writable, or the certificate generation failed.
**Resolution:**
1. Ensure the service account has write access to the PKI root directory.
2. Check that the `PkiRootPath` (if overridden) points to a valid, writable location.
3. Delete any corrupt certificate files in `pki/own/` and restart the server to trigger regeneration.
### SHA-1 certificate rejection
**Symptom:** A client with a valid certificate is rejected, and the server logs mention SHA-1.
**Cause:** The client's certificate was signed with SHA-1, and `RejectSHA1Certificates` is `true` (the default).
**Resolution:**
- Regenerate the client certificate using SHA-256 or stronger (recommended).
- Alternatively, set `RejectSHA1Certificates` to `false` in the server configuration (not recommended for production).

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;",

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);
}
}
}

View File

@@ -21,6 +21,9 @@ public class AlarmsCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the node to subscribe to for event notifications, typically a source object or the server node.
/// </summary>
@@ -45,7 +48,7 @@ public class AlarmsCommand : ICommand
/// <param name="console">The CLI console used for cancellation and alarm-event output.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var nodeId = string.IsNullOrEmpty(NodeId)
? ObjectIds.Server

View File

@@ -21,6 +21,9 @@ public class BrowseCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the optional node identifier to browse from; defaults to the OPC UA Objects folder.
/// </summary>
@@ -45,7 +48,7 @@ public class BrowseCommand : ICommand
/// <param name="console">The console used to emit browse output.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var startNode = string.IsNullOrEmpty(NodeId)
? ObjectIds.ObjectsFolder

View File

@@ -19,13 +19,16 @@ public class ConnectCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Connects to the OPC UA endpoint and prints the resolved server metadata.
/// </summary>
/// <param name="console">The console used to report connection results.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
await console.Output.WriteLineAsync($"Connected to: {session.Endpoint.EndpointUrl}");
await console.Output.WriteLineAsync($"Server: {session.Endpoint.Server!.ApplicationName}");
await console.Output.WriteLineAsync($"Security Mode: {session.Endpoint.SecurityMode}");

View File

@@ -21,6 +21,9 @@ public class HistoryReadCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the node identifier for the historized variable to query.
/// </summary>
@@ -63,7 +66,7 @@ public class HistoryReadCommand : ICommand
/// <param name="console">The CLI console used for output, errors, and cancellation handling.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var nodeId = new NodeId(NodeId);
var start = string.IsNullOrEmpty(StartTime) ? DateTime.UtcNow.AddHours(-24) : DateTime.Parse(StartTime).ToUniversalTime();

View File

@@ -21,6 +21,9 @@ public class ReadCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the node identifier whose value should be read.
/// </summary>
@@ -33,7 +36,7 @@ public class ReadCommand : ICommand
/// <param name="console">The console used to report the read result.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var node = new NodeId(NodeId);
var value = await session.ReadValueAsync(node);

View File

@@ -21,6 +21,9 @@ public class SubscribeCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the node identifier to monitor for value changes.
/// </summary>
@@ -39,7 +42,7 @@ public class SubscribeCommand : ICommand
/// <param name="console">The console used to display subscription updates.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var subscription = new Subscription(session.DefaultSubscription)
{

View File

@@ -21,6 +21,9 @@ public class WriteCommand : ICommand
[CommandOption("password", 'P', Description = "Password for authentication")]
public string? Password { get; init; }
[CommandOption("security", 'S', Description = "Transport security: none, sign, encrypt (default: none)")]
public string Security { get; init; } = "none";
/// <summary>
/// Gets the node identifier that should receive the write.
/// </summary>
@@ -39,7 +42,7 @@ public class WriteCommand : ICommand
/// <param name="console">The console used to report the write result.</param>
public async ValueTask ExecuteAsync(IConsole console)
{
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password);
using var session = await OpcUaHelper.ConnectAsync(Url, Username, Password, Security);
var node = new NodeId(NodeId);
var current = await session.ReadValueAsync(node);

View File

@@ -10,8 +10,12 @@ public static class OpcUaHelper
/// Creates an OPC UA client session for the specified endpoint URL.
/// </summary>
/// <param name="endpointUrl">The OPC UA endpoint URL to connect to.</param>
/// <param name="username">Optional username for authentication.</param>
/// <param name="password">Optional password for authentication.</param>
/// <param name="security">The requested transport security mode: "none", "sign", or "encrypt".</param>
/// <returns>An active OPC UA client session.</returns>
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null)
public static async Task<Session> ConnectAsync(string endpointUrl, string? username = null, string? password = null,
string security = "none")
{
var config = new ApplicationConfiguration
{
@@ -49,7 +53,28 @@ public static class OpcUaHelper
await config.Validate(ApplicationType.Client);
config.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
var endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
var requestedMode = ParseSecurityMode(security);
EndpointDescription endpoint;
if (requestedMode == MessageSecurityMode.None)
{
endpoint = CoreClientUtils.SelectEndpoint(config, endpointUrl, false);
}
else
{
// For secure connections, ensure the client has a certificate
var app = new ApplicationInstance
{
ApplicationName = "OpcUaCli",
ApplicationType = ApplicationType.Client,
ApplicationConfiguration = config
};
await app.CheckApplicationInstanceCertificatesAsync(false, 2048);
// Discover endpoints and pick the one matching the requested security mode
endpoint = SelectSecureEndpoint(config, endpointUrl, requestedMode);
}
var endpointConfig = EndpointConfiguration.Create(config);
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, endpointConfig);
@@ -70,6 +95,73 @@ public static class OpcUaHelper
#pragma warning restore CS0618
}
/// <summary>
/// Parses the security mode string from the CLI option.
/// </summary>
private static MessageSecurityMode ParseSecurityMode(string security)
{
return (security ?? "none").Trim().ToLowerInvariant() switch
{
"none" => MessageSecurityMode.None,
"sign" => MessageSecurityMode.Sign,
"encrypt" or "signandencrypt" => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentException(
$"Unknown security mode '{security}'. Valid values: none, sign, encrypt")
};
}
/// <summary>
/// Discovers server endpoints and selects one matching the requested security mode,
/// preferring Basic256Sha256 when multiple matches exist.
/// </summary>
private static EndpointDescription SelectSecureEndpoint(ApplicationConfiguration config,
string endpointUrl, MessageSecurityMode requestedMode)
{
// Use discovery to get all endpoints
using var client = DiscoveryClient.Create(new Uri(endpointUrl));
var allEndpoints = client.GetEndpoints(null);
EndpointDescription? best = null;
foreach (var ep in allEndpoints)
{
if (ep.SecurityMode != requestedMode)
continue;
if (best == null)
{
best = ep;
continue;
}
// Prefer Basic256Sha256
if (ep.SecurityPolicyUri == SecurityPolicies.Basic256Sha256)
best = ep;
}
if (best == null)
{
var available = string.Join(", ", allEndpoints.Select(e => $"{e.SecurityMode}/{e.SecurityPolicyUri}"));
throw new InvalidOperationException(
$"No endpoint found with security mode '{requestedMode}'. Available endpoints: {available}");
}
// Rewrite endpoint URL to use the user-supplied hostname instead of the server's
// internal address (e.g., 0.0.0.0 -> localhost) to handle NAT/hostname differences
var serverUri = new Uri(best.EndpointUrl);
var requestedUri = new Uri(endpointUrl);
if (serverUri.Host != requestedUri.Host)
{
var builder = new UriBuilder(best.EndpointUrl)
{
Host = requestedUri.Host
};
best.EndpointUrl = builder.ToString();
}
return best;
}
/// <summary>
/// Converts a raw command-line string into the runtime type expected by the target node.
/// </summary>