feat(opcua): F13b endpoint security profiles — Sign + SignAndEncrypt
OpcUaApplicationHost.BuildConfigurationAsync now populates ServerConfiguration.SecurityPolicies + UserTokenPolicies from the new OpcUaSecurityProfile enum on OpcUaApplicationHostOptions. Defaults expose all three baseline profiles (None + Basic256Sha256-Sign + Basic256Sha256-SignAndEncrypt) matching docs/security.md. UserName tokens are SDK-encrypted with the server cert so they work on None endpoints too; F13c will plug the LDAP validator into SessionManager. AutoAcceptUntrustedClientCertificates surfaces as an option for dev flows; production keeps the default (false) and operators promote rejected certs through the Admin UI. InternalsVisibleTo added so BuildSecurityPolicies / BuildUserTokenPolicies stay encapsulated but unit-testable. 6 new tests cover the pure builders + two boot-verify cases (3-profile default + hardened single-profile), bringing the suite to 34 / 34 passing. Closes #103. Unblocks #104 (F13c LDAP user-token validator).
This commit is contained in:
@@ -5,6 +5,22 @@ using Opc.Ua.Server;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// Transport-security profile served by the OPC UA endpoint. F13b ships the three baseline
|
||||
/// profiles defined by docs/security.md; the remaining Aes128/Aes256 variants can be added
|
||||
/// later by extending <see cref="OpcUaSecurityProfile.PolicyUri"/>+<see cref="OpcUaSecurityProfile.Mode"/>
|
||||
/// — the wiring in <c>BuildConfigurationAsync</c> is profile-agnostic.
|
||||
/// </summary>
|
||||
public enum OpcUaSecurityProfile
|
||||
{
|
||||
/// <summary>No signing or encryption. Dev / isolated networks only.</summary>
|
||||
None,
|
||||
/// <summary>Basic256Sha256 + Sign. Messages signed, payload visible on the wire.</summary>
|
||||
Basic256Sha256Sign,
|
||||
/// <summary>Basic256Sha256 + SignAndEncrypt. Full transport protection.</summary>
|
||||
Basic256Sha256SignAndEncrypt,
|
||||
}
|
||||
|
||||
public sealed class OpcUaApplicationHostOptions
|
||||
{
|
||||
public string ApplicationName { get; set; } = "OtOpcUa";
|
||||
@@ -26,6 +42,26 @@ public sealed class OpcUaApplicationHostOptions
|
||||
/// to "pki" (relative to the host's working directory) to keep dev flows identical to v1.
|
||||
/// </summary>
|
||||
public string PkiStoreRoot { get; set; } = "pki";
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public IList<OpcUaSecurityProfile> EnabledSecurityProfiles { get; set; } = new List<OpcUaSecurityProfile>
|
||||
{
|
||||
OpcUaSecurityProfile.None,
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign,
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt,
|
||||
};
|
||||
|
||||
/// <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
|
||||
/// the Admin UI). Has no effect on <c>None</c> endpoints, which don't exchange certs.
|
||||
/// </summary>
|
||||
public bool AutoAcceptUntrustedClientCertificates { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -103,21 +139,30 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return await _application!.LoadApplicationConfiguration(_options.ApplicationConfigPath, silent: true);
|
||||
}
|
||||
|
||||
// Minimal defaults — security and certificate stores hardcoded to local files in
|
||||
// the app's working directory. Full security wiring stays in legacy Server until F13.
|
||||
var serverConfig = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
};
|
||||
|
||||
foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles))
|
||||
{
|
||||
serverConfig.SecurityPolicies.Add(policy);
|
||||
}
|
||||
foreach (var token in BuildUserTokenPolicies())
|
||||
{
|
||||
serverConfig.UserTokenPolicies.Add(token);
|
||||
}
|
||||
|
||||
var config = new ApplicationConfiguration
|
||||
{
|
||||
ApplicationName = _options.ApplicationName,
|
||||
ApplicationUri = _options.ApplicationUri,
|
||||
ProductUri = _options.ProductUri,
|
||||
ApplicationType = ApplicationType.Server,
|
||||
ServerConfiguration = new ServerConfiguration
|
||||
{
|
||||
BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" },
|
||||
MinRequestThreadCount = 5,
|
||||
MaxRequestThreadCount = 100,
|
||||
MaxQueuedRequestCount = 200,
|
||||
},
|
||||
ServerConfiguration = serverConfig,
|
||||
SecurityConfiguration = new SecurityConfiguration
|
||||
{
|
||||
ApplicationCertificate = new CertificateIdentifier
|
||||
@@ -129,7 +174,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
TrustedIssuerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "issuer") },
|
||||
TrustedPeerCertificates = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "trusted") },
|
||||
RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") },
|
||||
AutoAcceptUntrustedCertificates = false,
|
||||
AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates,
|
||||
},
|
||||
TransportQuotas = new TransportQuotas(),
|
||||
ClientConfiguration = new ClientConfiguration(),
|
||||
@@ -141,6 +186,71 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
||||
return config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps each configured <see cref="OpcUaSecurityProfile"/> to a SDK
|
||||
/// <see cref="ServerSecurityPolicy"/>. Duplicate profiles are silently de-duped because
|
||||
/// the SDK rejects duplicate (policy,mode) pairs at <c>Validate</c> time. Empty input
|
||||
/// falls back to a single None entry so the server doesn't refuse to start with no
|
||||
/// listening endpoints — the misconfiguration is logged and very visible.
|
||||
/// </summary>
|
||||
internal static IEnumerable<ServerSecurityPolicy> BuildSecurityPolicies(IEnumerable<OpcUaSecurityProfile> profiles)
|
||||
{
|
||||
var seen = new HashSet<OpcUaSecurityProfile>();
|
||||
var any = false;
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
if (!seen.Add(profile)) continue;
|
||||
any = true;
|
||||
yield return profile switch
|
||||
{
|
||||
OpcUaSecurityProfile.None => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
},
|
||||
OpcUaSecurityProfile.Basic256Sha256Sign => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.Sign,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
},
|
||||
OpcUaSecurityProfile.Basic256Sha256SignAndEncrypt => new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.SignAndEncrypt,
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
},
|
||||
_ => throw new InvalidOperationException($"Unknown OpcUaSecurityProfile: {profile}"),
|
||||
};
|
||||
}
|
||||
if (!any)
|
||||
{
|
||||
yield return new ServerSecurityPolicy
|
||||
{
|
||||
SecurityMode = MessageSecurityMode.None,
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous + UserName token policies. UserName tokens are always SDK-encrypted with
|
||||
/// the server certificate (see docs/security.md "UserName token encryption") so the
|
||||
/// policy works on None endpoints too. F13c will plug a real LDAP-bound validator into
|
||||
/// <c>StandardServer.SessionManager.ImpersonateUser</c>.
|
||||
/// </summary>
|
||||
internal static IEnumerable<UserTokenPolicy> BuildUserTokenPolicies()
|
||||
{
|
||||
yield return new UserTokenPolicy(UserTokenType.Anonymous)
|
||||
{
|
||||
PolicyId = "anonymous",
|
||||
SecurityPolicyUri = SecurityPolicies.None,
|
||||
};
|
||||
yield return new UserTokenPolicy(UserTokenType.UserName)
|
||||
{
|
||||
PolicyId = "username_basic256sha256",
|
||||
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
||||
};
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try { _application?.Stop(); }
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Configuration\ZB.MOM.WW.OtOpcUa.Configuration.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
|
||||
Reference in New Issue
Block a user