fix(opcua): a configured OpcUa:EnabledSecurityProfiles list replaces the default, so None can be dropped (#879)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
ConfigurationBinder appends configured collection items to an existing list, so the pre-filled [None, Sign, SignAndEncrypt] default made "drop None" a no-op: every deployment that listed only the secure profiles still advertised an unsigned/unencrypted None endpoint. EnabledSecurityProfiles is now null by default; ResolveEnabledSecurityProfiles() applies the unchanged three-profile default only when nothing is configured. A set-but-empty list fails startup validation and BuildSecurityPolicies no longer falls back to a None endpoint on empty input (it throws). Sweep of all options classes found no other non-empty pre-filled collection default. Tests: options binding (indexed, JSON, env forms, default, empty, bad name), a booted-server wire test (no None endpoint, None connection refused with BadSecurityPolicyRejected, secure connection works; default case is the control). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
This commit is contained in:
@@ -348,7 +348,7 @@ See `docs/v2/dev-environment.md` for the full inventory and rationale.
|
||||
|
||||
## Transport Security
|
||||
|
||||
The server supports configurable OPC UA transport security via the `OpcUa:EnabledSecurityProfiles` list in `appsettings.json`. Phase 1 profiles (the `OpcUaSecurityProfile` enum members): `None` (default), `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`. Security policies are built from the enabled profiles by `BuildSecurityPolicies` 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.
|
||||
The server supports configurable OPC UA transport security via the `OpcUa:EnabledSecurityProfiles` list in `appsettings.json`. Phase 1 profiles (the `OpcUaSecurityProfile` enum members): `None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`. The default (key absent) is all three; a configured list REPLACES it (lmxopcua#879 — the property is null by default and `ResolveEnabledSecurityProfiles()` applies the default, because `ConfigurationBinder` appends to a pre-filled list); a set-but-empty list fails validation. Security policies are built from the enabled profiles by `BuildSecurityPolicies` 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
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ Until #678.6 the overlay never loaded on a node carrying a `cluster-*` role (the
|
||||
| `PublicHostname` | string | `0.0.0.0` | Hostname/IP advertised in endpoint descriptions. Required. |
|
||||
| `ApplicationConfigPath` | string? | `null` | Optional path to an application config XML; loaded instead of building from defaults. |
|
||||
| `PkiStoreRoot` | string | `pki` | Root of the PKI hierarchy (`own`/`issuer`/`trusted`/`rejected` substores created under it). Required. See [`security.md`](security.md). |
|
||||
| `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. Must contain ≥1. Profile detail in [`security.md`](security.md). |
|
||||
| `EnabledSecurityProfiles` | list of `OpcUaSecurityProfile` | _(absent)_ = `[None, Basic256Sha256Sign, Basic256Sha256SignAndEncrypt]` | Transport-security profiles, one endpoint per entry. **A configured list replaces the default** (#879); the default applies only when the key is absent. A set-but-empty list fails startup validation. Profile detail in [`security.md`](security.md). |
|
||||
| `AutoAcceptUntrustedClientCertificates` | bool | `false` | Auto-trust unknown client certs on first connect (dev convenience). Not validated. See [`security.md`](security.md). |
|
||||
| `PeerApplicationUris` | list of string | `[]` (empty) | Partner node `ApplicationUri`s published in `Server.ServerArray` for redundancy discovery. See [`Redundancy.md`](Redundancy.md). |
|
||||
| `DiscoveryServerUrl` | string? | `null` | OPC UA discovery server (LDS/GDS) to register with via `RegisterServer2`, e.g. `opc.tcp://lds-host:4840`. **Unset = no registration** (#876 — the SDK default registered with `opc.tcp://localhost:4840`, i.e. the server itself, and logged `BadServiceUnsupported` forever). Must be an absolute `opc.tcp://` URL when set (validated). Ignored when `ApplicationConfigPath` is set (the XML's own `RegistrationEndpoint`/`MaxRegistrationInterval` apply). |
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ Environmental knobs that aren't per-tenant — bind address, port, PKI store roo
|
||||
|
||||
## Transport
|
||||
|
||||
The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpcUa` (defaults `0.0.0.0:4840`). The `ApplicationConfiguration` is built programmatically in `OpcUaApplicationHost.BuildConfigurationAsync` — there are no UA XML files unless `ApplicationConfigPath` is set. Security profiles are listed in `OpcUaApplicationHostOptions.EnabledSecurityProfiles`; by default all three baseline profiles are exposed (`None`, `Basic256Sha256` + Sign, `Basic256Sha256` + SignAndEncrypt) and the SDK publishes one endpoint descriptor per profile. Production deployments typically drop `None`. User token policies (`Anonymous`, `UserName`) are always attached; the `UserName` policy is SDK-encrypted with the server certificate so it works on `None` endpoints too. See `docs/security.md` for hardening.
|
||||
The server binds a TCP endpoint at `opc.tcp://{PublicHostname}:{OpcUaPort}/OtOpcUa` (defaults `0.0.0.0:4840`). The `ApplicationConfiguration` is built programmatically in `OpcUaApplicationHost.BuildConfigurationAsync` — there are no UA XML files unless `ApplicationConfigPath` is set. Security profiles are listed in `OpcUaApplicationHostOptions.EnabledSecurityProfiles`; by default (key absent) all three baseline profiles are exposed (`None`, `Basic256Sha256` + Sign, `Basic256Sha256` + SignAndEncrypt) and the SDK publishes one endpoint descriptor per profile. A configured list replaces that default (lmxopcua#879). Production deployments typically drop `None`. User token policies (`Anonymous`, `UserName`) are always attached; the `UserName` policy is SDK-encrypted with the server certificate so it works on `None` endpoints too. See `docs/security.md` for hardening.
|
||||
|
||||
## Session impersonation
|
||||
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ The profiles are the members of the `OpcUaSecurityProfile` enum (`src/Server/ZB.
|
||||
|
||||
`BuildSecurityPolicies` (`OpcUaApplicationHost.cs`) maps each configured profile to an SDK `ServerSecurityPolicy`. The server exposes a separate endpoint per configured profile and clients select the one they prefer at session open. The enum's XML doc notes that Aes128/Aes256 variants can be added later by extending the enum + `BuildSecurityPolicies` — the wiring is profile-agnostic — but they are **not implemented today**. There is no `SecurityProfileResolver` class.
|
||||
|
||||
> **Config value form.** The enum binds by member name, so a profile string with hyphens (e.g. `Basic256Sha256-Sign`) does **not** bind — use the exact enum-member spelling above. If `EnabledSecurityProfiles` is empty, the server falls back to a single `None` endpoint (logged, very visible) so it still has a listening endpoint.
|
||||
> **Config value form.** The enum binds by member name, so a profile string with hyphens (e.g. `Basic256Sha256-Sign`) does **not** bind — use the exact enum-member spelling above. An empty profile set is never accepted: startup validation rejects a set-but-empty list and `BuildSecurityPolicies` throws on empty input — the server no longer falls back to a `None` endpoint (lmxopcua#879).
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -108,7 +108,7 @@ Transport security is configured in the `OpcUa` section of the Host process's bo
|
||||
}
|
||||
```
|
||||
|
||||
`EnabledSecurityProfiles` is a **list** — the server publishes one endpoint per entry. The default (when the key is omitted) is all three baseline profiles (`None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`); production deployments typically drop `None`. The list must contain at least one entry (`OpcUaApplicationHostOptionsValidator` enforces `MinCount(…, 1)`).
|
||||
`EnabledSecurityProfiles` is a **list** — the server publishes one endpoint per entry. **A configured list replaces the default; it is never merged with it** (lmxopcua#879 — before the fix the binder appended your entries to a pre-filled `[None, Sign, SignAndEncrypt]`, so `None` could not be dropped; any deployment that "dropped `None`" on an older build still advertised it). The default applies only when the key is absent (or an empty `[]`, which binds to nothing): all three baseline profiles (`None`, `Basic256Sha256Sign`, `Basic256Sha256SignAndEncrypt`). A list that is set but empty (reachable only from code) is a configuration error — `OpcUaApplicationHostOptionsValidator` fails startup — and never means "None only" or "everything". An unrecognised profile name fails the bind rather than being ignored. The same holds for the environment-variable form (`OpcUa__EnabledSecurityProfiles__0=…`). Verify with a `GetEndpoints` probe after changing it.
|
||||
|
||||
The server certificate is auto-generated on first start if none exists in `PkiStoreRoot/own/`. Always generated even for `None`-only deployments because UserName token encryption depends on it.
|
||||
|
||||
|
||||
@@ -8,4 +8,4 @@ var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<str
|
||||
}).Build();
|
||||
var options = new OpcUaApplicationHostOptions();
|
||||
config.GetSection("OpcUa").Bind(options);
|
||||
Console.WriteLine(string.Join(", ", options.EnabledSecurityProfiles));
|
||||
Console.WriteLine(string.Join(", ", options.ResolveEnabledSecurityProfiles()));
|
||||
|
||||
@@ -83,7 +83,7 @@ upgrade or rollback), so `baseline-host` and `install-baseline` are not needed:
|
||||
- the elevated administrator is refused on the worker pipe;
|
||||
- `script-runner` health and the PE cache.
|
||||
- **Findings, not gating.** The script records which endpoints the server advertises, and the result of a session over the
|
||||
`None` endpoint (lmxopcua#879).
|
||||
`None` endpoint (lmxopcua#879). The #879 fix landed after the 2026-11-12 windev run: a build with it must advertise no `None` endpoint when only the secure profiles are configured, and the `None` session must be refused.
|
||||
|
||||
**Cleanup caution.** `Cleanup` deletes `OtOpcUaHost` with `sc.exe delete`. It deliberately does not run
|
||||
`scripts/install/Uninstall-Services.ps1`, because that script also stops and deletes `OtOpcUaWonderwareHistorian`, which
|
||||
|
||||
+5
-3
@@ -25,10 +25,12 @@ public sealed class OpcUaApplicationHostOptionsValidator : OptionsValidatorBase<
|
||||
builder.Required(o.PublicHostname, "OpcUa:PublicHostname");
|
||||
builder.Required(o.PkiStoreRoot, "OpcUa:PkiStoreRoot");
|
||||
builder.Port(o.OpcUaPort, "OpcUa:OpcUaPort");
|
||||
// lmxopcua#879: null (not configured) means "use the default three profiles" and is valid; a list that is
|
||||
// present but empty is an error — it must never silently mean "None only" or "everything".
|
||||
// EnabledSecurityProfiles is declared as IList<T> — that interface does not derive from
|
||||
// IReadOnlyCollection<T>, so it can't bind to MinCount's IReadOnlyCollection<T> parameter
|
||||
// directly. ToList() bridges to the shared primitive while preserving the count (and message).
|
||||
builder.MinCount(o.EnabledSecurityProfiles?.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
||||
// IReadOnlyCollection<T>, so ToList() bridges to MinCount's parameter (count + message preserved).
|
||||
if (o.EnabledSecurityProfiles is not null)
|
||||
builder.MinCount(o.EnabledSecurityProfiles.ToList(), 1, "OpcUa:EnabledSecurityProfiles");
|
||||
|
||||
// #876: discovery registration is opt-in; when configured, the URL must be an absolute opc.tcp URI and the
|
||||
// interval positive (0 would silently disable the registration that was asked for).
|
||||
|
||||
@@ -79,18 +79,54 @@ public sealed class OpcUaApplicationHostOptions
|
||||
public bool AllowAnonymous { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-security profiles exposed by the server. The SDK publishes one endpoint
|
||||
/// descriptor per profile and clients choose at session open. Default = all three
|
||||
/// baseline profiles (None + Basic256Sha256 in both modes); production deployments
|
||||
/// typically drop None.
|
||||
/// Transport-security profiles exposed by the server (<c>OpcUa:EnabledSecurityProfiles</c>); the SDK
|
||||
/// publishes one endpoint descriptor per profile and clients choose at session open.
|
||||
/// </summary>
|
||||
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Replace semantics (lmxopcua#879).</b> This is <see langword="null"/> by default and the
|
||||
/// three-profile default (<see cref="DefaultSecurityProfiles"/>: <c>None</c> +
|
||||
/// <c>Basic256Sha256Sign</c> + <c>Basic256Sha256SignAndEncrypt</c>) is applied by
|
||||
/// <see cref="ResolveEnabledSecurityProfiles"/> only when nothing was configured. It must NOT be
|
||||
/// pre-filled: <c>ConfigurationBinder</c> appends configured collection items to an existing list
|
||||
/// instead of replacing it, so a pre-filled default made it impossible to drop <c>None</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Empty list.</b> A key that is absent, or an array with no items (<c>[]</c> binds to nothing),
|
||||
/// means "not configured" and yields the default. A list that is non-null but empty (only
|
||||
/// reachable from code, or an env value the binder materialises as an empty list) is a
|
||||
/// configuration error: <c>OpcUaApplicationHostOptionsValidator</c> fails startup and
|
||||
/// <see cref="ResolveEnabledSecurityProfiles"/> throws. It never falls back to <c>None</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public IList<OpcUaSecurityProfile>? EnabledSecurityProfiles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The profiles applied when <see cref="EnabledSecurityProfiles"/> is not configured — all three
|
||||
/// baseline profiles, unchanged from before lmxopcua#879. Production deployments configure the list
|
||||
/// explicitly (without <c>None</c>) to drop the unsecured endpoint.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<OpcUaSecurityProfile> DefaultSecurityProfiles { get; } = new[]
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The profiles the server actually publishes: the configured list when one was given, otherwise
|
||||
/// <see cref="DefaultSecurityProfiles"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The list was set but is empty.</exception>
|
||||
public IReadOnlyList<OpcUaSecurityProfile> ResolveEnabledSecurityProfiles()
|
||||
{
|
||||
if (EnabledSecurityProfiles is null) return DefaultSecurityProfiles;
|
||||
if (EnabledSecurityProfiles.Count == 0)
|
||||
throw new InvalidOperationException(
|
||||
"OpcUa:EnabledSecurityProfiles is set but empty; list at least one profile or omit the key to use the default.");
|
||||
return EnabledSecurityProfiles.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, unknown client certificates are auto-added to the trusted store on first
|
||||
/// connection. Convenient for dev; should be false in production (operators promote via
|
||||
@@ -635,7 +671,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
DisableSdkAuthenticationLockout(serverConfig);
|
||||
ApplyDiscoveryRegistration(serverConfig, _options);
|
||||
|
||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.EnabledSecurityProfiles).ToArray();
|
||||
serverConfig.SecurityPolicies = BuildSecurityPolicies(_options.ResolveEnabledSecurityProfiles()).ToArray();
|
||||
serverConfig.UserTokenPolicies = BuildUserTokenPolicies(_options.AllowAnonymous).ToArray();
|
||||
|
||||
var config = new ApplicationConfiguration(_telemetry)
|
||||
@@ -779,14 +815,11 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
|
||||
};
|
||||
}
|
||||
// lmxopcua#879: an empty profile list used to fall back to a single None endpoint. A misconfiguration must
|
||||
// never silently widen the transport to unsecured, so it is an error (the host validator rejects it first).
|
||||
if (!any)
|
||||
{
|
||||
yield return new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
}
|
||||
throw new InvalidOperationException(
|
||||
"No OPC UA security profile is enabled; OpcUa:EnabledSecurityProfiles must list at least one.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#879 — <c>ConfigurationBinder</c> appends configured collection items to a pre-filled list, so
|
||||
/// <c>OpcUa:EnabledSecurityProfiles</c> could never drop <c>None</c>. These tests pin "the configured list REPLACES
|
||||
/// the default" through the same <c>AddValidatedOptions</c> wiring <c>Program.cs</c> uses, for the indexed
|
||||
/// (env-var / in-memory) form and the JSON array form, plus the unchanged default and the empty-list rule.
|
||||
/// </summary>
|
||||
public sealed class OpcUaSecurityProfilesBindingTests
|
||||
{
|
||||
private static readonly OpcUaSecurityProfile[] AllThree =
|
||||
[
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
];
|
||||
|
||||
private static IOptions<OpcUaApplicationHostOptions> Resolve(IConfiguration configuration, out ServiceProvider provider)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(configuration);
|
||||
services.AddValidatedOptions<OpcUaApplicationHostOptions, OpcUaApplicationHostOptionsValidator>(configuration, "OpcUa");
|
||||
provider = services.BuildServiceProvider();
|
||||
return provider.GetRequiredService<IOptions<OpcUaApplicationHostOptions>>();
|
||||
}
|
||||
|
||||
/// <summary>The #879 repro: only the secure profiles configured gives exactly those, no None.</summary>
|
||||
[Fact]
|
||||
public void Configured_secure_profiles_replace_the_default_and_drop_None()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256SignAndEncrypt",
|
||||
["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256Sign",
|
||||
}).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(
|
||||
[OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt, OpcUaSecurityProfile.Basic256Sha256Sign]);
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldNotContain(OpcUaSecurityProfile.None);
|
||||
}
|
||||
|
||||
/// <summary>The JSON array form (appsettings.json, as docs/security.md shows) behaves the same as the indexed form.</summary>
|
||||
[Fact]
|
||||
public void Json_array_form_replaces_the_default_too()
|
||||
{
|
||||
var json = """{ "OpcUa": { "EnabledSecurityProfiles": [ "Basic256Sha256Sign", "Basic256Sha256SignAndEncrypt" ] } }""";
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(
|
||||
[OpcUaSecurityProfile.Basic256Sha256Sign, OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]);
|
||||
}
|
||||
|
||||
/// <summary>The environment-variable form (<c>OpcUa__EnabledSecurityProfiles__0</c>) used by the Windows service gate.</summary>
|
||||
[Fact]
|
||||
public void Environment_variable_form_replaces_the_default()
|
||||
{
|
||||
const string prefix = "OTOPCUA_879_TEST_";
|
||||
Environment.SetEnvironmentVariable(prefix + "OpcUa__EnabledSecurityProfiles__0", "Basic256Sha256SignAndEncrypt");
|
||||
try
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build();
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe([OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt]);
|
||||
}
|
||||
finally { Environment.SetEnvironmentVariable(prefix + "OpcUa__EnabledSecurityProfiles__0", null); }
|
||||
}
|
||||
|
||||
/// <summary>Configuring None explicitly alongside the secure profiles still works (an operator may want it).</summary>
|
||||
[Fact]
|
||||
public void Explicit_None_is_honoured_when_listed()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:EnabledSecurityProfiles:0"] = "None",
|
||||
}).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe([OpcUaSecurityProfile.None]);
|
||||
}
|
||||
|
||||
/// <summary>Nothing configured: the three-profile default is applied, unchanged from before the fix.</summary>
|
||||
[Fact]
|
||||
public void Nothing_configured_keeps_the_three_profile_default()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:OpcUaPort"] = "4841", // a section exists, the list key does not
|
||||
}).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
|
||||
new OpcUaApplicationHostOptions().ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
|
||||
}
|
||||
|
||||
/// <summary>An empty JSON array binds to nothing, i.e. "not configured": the default, never "None only".</summary>
|
||||
[Fact]
|
||||
public void Empty_json_array_is_treated_as_not_configured_and_yields_the_default()
|
||||
{
|
||||
var json = """{ "OpcUa": { "EnabledSecurityProfiles": [] } }""";
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonStream(new MemoryStream(Encoding.UTF8.GetBytes(json))).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
options.Value.ResolveEnabledSecurityProfiles().ShouldBe(AllThree);
|
||||
}
|
||||
|
||||
/// <summary>A list that is set but empty fails startup validation and cannot be resolved — it never means "None".</summary>
|
||||
[Fact]
|
||||
public void An_explicitly_empty_list_fails_validation_and_resolution()
|
||||
{
|
||||
var empty = new OpcUaApplicationHostOptions { EnabledSecurityProfiles = new List<OpcUaSecurityProfile>() };
|
||||
|
||||
new OpcUaApplicationHostOptionsValidator().Validate(null, empty).Failed.ShouldBeTrue();
|
||||
Should.Throw<InvalidOperationException>(() => empty.ResolveEnabledSecurityProfiles());
|
||||
}
|
||||
|
||||
/// <summary>An unparseable profile name fails startup rather than being ignored (which would otherwise leave the default).</summary>
|
||||
[Fact]
|
||||
public void An_unknown_profile_name_fails_options_resolution()
|
||||
{
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256-SignAndEncrypt",
|
||||
}).Build();
|
||||
|
||||
var options = Resolve(configuration, out var provider);
|
||||
using var sp = provider;
|
||||
|
||||
Should.Throw<Exception>(() => _ = options.Value.ResolveEnabledSecurityProfiles());
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Client;
|
||||
using Opc.Ua.Configuration;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#879 over the wire. The options come from a real <c>ConfigurationBinder</c> bind (the path the Host
|
||||
/// uses), so a regression to a pre-filled list shows up as an advertised <c>None</c> endpoint that admits a
|
||||
/// session. The default case is the positive control: the same probes must find and use <c>None</c> there.
|
||||
/// </summary>
|
||||
public sealed class SecurityProfilesReplaceWireTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task Configuring_only_the_secure_profiles_removes_the_None_endpoint_and_refuses_a_None_connection()
|
||||
{
|
||||
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>
|
||||
{
|
||||
["OpcUa:EnabledSecurityProfiles:0"] = "Basic256Sha256Sign",
|
||||
["OpcUa:EnabledSecurityProfiles:1"] = "Basic256Sha256SignAndEncrypt",
|
||||
}, Ct);
|
||||
|
||||
var modes = (await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray();
|
||||
modes.ShouldBe([MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
|
||||
|
||||
// Positive control: the secure endpoint really is usable, so the refusal below is about None, not about the rig.
|
||||
using (var secure = await rig.ConnectAsync(MessageSecurityMode.SignAndEncrypt, Ct))
|
||||
secure.Connected.ShouldBeTrue();
|
||||
|
||||
var refusal = await Should.ThrowAsync<ServiceResultException>(() => rig.ConnectAsync(MessageSecurityMode.None, Ct));
|
||||
refusal.StatusCode.ShouldBe(StatusCodes.BadSecurityPolicyRejected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nothing_configured_still_advertises_all_three_and_a_None_connection_is_admitted()
|
||||
{
|
||||
await using var rig = await Rig.StartAsync(new Dictionary<string, string?>(), Ct);
|
||||
|
||||
(await rig.GetEndpointModesAsync(Ct)).Distinct().Order().ToArray()
|
||||
.ShouldBe([MessageSecurityMode.None, MessageSecurityMode.Sign, MessageSecurityMode.SignAndEncrypt]);
|
||||
using var none = await rig.ConnectAsync(MessageSecurityMode.None, Ct);
|
||||
none.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private sealed class Rig : IAsyncDisposable
|
||||
{
|
||||
private readonly string _pki = TestClientSecurity.AllocatePkiRoot();
|
||||
private readonly string _clientPki = TestClientSecurity.AllocatePkiRoot();
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private int _port;
|
||||
|
||||
public static async Task<Rig> StartAsync(Dictionary<string, string?> values, CancellationToken ct)
|
||||
{
|
||||
var rig = new Rig();
|
||||
var section = new ConfigurationBuilder().AddInMemoryCollection(values).Build();
|
||||
try
|
||||
{
|
||||
(rig._host, _) = await OpcUaHostStart.StartAsync(port =>
|
||||
{
|
||||
var options = new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Sec879",
|
||||
ApplicationUri = $"urn:OtOpcUa.Sec879:{Guid.NewGuid():N}",
|
||||
PublicHostname = "127.0.0.1",
|
||||
PkiStoreRoot = rig._pki,
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
};
|
||||
section.GetSection("OpcUa").Bind(options); // the real binder, exactly what AddValidatedOptions does
|
||||
options.OpcUaPort = port;
|
||||
rig._port = port;
|
||||
return new OpcUaApplicationHost(options, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
}, () => new OtOpcUaSdkServer(), ct);
|
||||
return rig;
|
||||
}
|
||||
catch { await rig.DisposeAsync(); throw; }
|
||||
}
|
||||
|
||||
private string Url => $"opc.tcp://127.0.0.1:{_port}/OtOpcUa";
|
||||
|
||||
private (ApplicationConfiguration Config, ApplicationInstance App)? _client;
|
||||
|
||||
private async Task<(ApplicationConfiguration Config, ApplicationInstance App)> ClientAsync(CancellationToken ct)
|
||||
{
|
||||
if (_client is { } cached) return cached;
|
||||
var security = TestClientSecurity.Build(_clientPki);
|
||||
security.ApplicationCertificate!.SubjectName = "CN=OtOpcUa.Sec879Client";
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var config = new ApplicationConfiguration(telemetry)
|
||||
{
|
||||
ApplicationName = "OtOpcUa.Sec879Client",
|
||||
ApplicationUri = $"urn:OtOpcUa.Sec879Client.{Guid.NewGuid():N}",
|
||||
ApplicationType = ApplicationType.Client,
|
||||
SecurityConfiguration = security,
|
||||
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
||||
};
|
||||
await config.ValidateAsync(ApplicationType.Client, ct);
|
||||
var app = new ApplicationInstance(telemetry) { ApplicationName = config.ApplicationName, ApplicationType = ApplicationType.Client };
|
||||
app.ApplicationConfiguration = config;
|
||||
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct);
|
||||
_client = (config, app);
|
||||
return (config, app);
|
||||
}
|
||||
|
||||
public async Task<MessageSecurityMode[]> GetEndpointModesAsync(CancellationToken ct)
|
||||
{
|
||||
var (config, _) = await ClientAsync(ct);
|
||||
using var discovery = await DiscoveryClient.CreateAsync(config, new Uri(Url), DiagnosticsMasks.None, ct);
|
||||
var endpoints = await discovery.GetEndpointsAsync(default, ct);
|
||||
return endpoints.ToArray()!.Select(e => e.SecurityMode).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an anonymous session on the endpoint of the requested mode. For <c>None</c> the endpoint
|
||||
/// description is synthesised rather than discovered, so a server that no longer advertises it is
|
||||
/// probed with exactly what a downgrading client would send.
|
||||
/// </summary>
|
||||
public async Task<ISession> ConnectAsync(MessageSecurityMode mode, CancellationToken ct)
|
||||
{
|
||||
var (config, _) = await ClientAsync(ct);
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
// Always start from a discovered secure endpoint (its token policies are what the server really sent);
|
||||
// None is a clone of it with the mode and policy downgraded, i.e. what a downgrading client sends.
|
||||
var description = await CoreClientUtils.SelectEndpointAsync(config, Url, true, telemetry, ct)
|
||||
?? throw new InvalidOperationException("No secure endpoint.");
|
||||
if (mode == MessageSecurityMode.None)
|
||||
{
|
||||
description = (EndpointDescription)description.Clone();
|
||||
description.SecurityMode = MessageSecurityMode.None;
|
||||
description.SecurityPolicyUri = SecurityPolicies.None;
|
||||
description.ServerCertificate = default;
|
||||
}
|
||||
else description.SecurityMode.ShouldBe(mode);
|
||||
return await new DefaultSessionFactory(telemetry).CreateAsync(
|
||||
config, new ConfiguredEndpoint(null, description, EndpointConfiguration.Create(config)),
|
||||
updateBeforeConnect: false, checkDomain: false, sessionName: "Sec879", sessionTimeout: 60_000,
|
||||
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: default, ct: ct);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_client is { } client) await client.App.DisposeAsync();
|
||||
if (_host is not null) await _host.DisposeAsync();
|
||||
foreach (var dir in new[] { _pki, _clientPki })
|
||||
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-8
@@ -61,16 +61,13 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that BuildSecurityPolicies falls back to None when given empty input.
|
||||
/// lmxopcua#879: an empty profile list is an error, never a silent fallback to an unsecured None endpoint.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildSecurityPolicies_empty_input_falls_back_to_none()
|
||||
public void BuildSecurityPolicies_empty_input_throws_instead_of_falling_back_to_none()
|
||||
{
|
||||
var policies = OpcUaApplicationHost.BuildSecurityPolicies(Array.Empty<OpcUaSecurityProfile>()).ToList();
|
||||
|
||||
policies.Count.ShouldBe(1);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.None);
|
||||
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.None);
|
||||
Should.Throw<InvalidOperationException>(
|
||||
() => OpcUaApplicationHost.BuildSecurityPolicies(Array.Empty<OpcUaSecurityProfile>()).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,7 +115,7 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
OpcUaPort = port,
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
EnabledSecurityProfiles =
|
||||
EnabledSecurityProfiles = new List<OpcUaSecurityProfile>
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
|
||||
Reference in New Issue
Block a user