Files
UA-.NETStandard/samples/Quickstarts.Servers/ReferenceServer/ReferenceServer.cs
romanett 5572107ab8 Push Model compliance: UpdateCertificate issuer check, TrustList audit events, NoConfiguration state, DefaultHttpsGroup (#4684)
# Description

Fixes found by a manual run of the OPC 10000-12 *Push Model for Global
Certificate and TrustList Management* test plan (183 test cases,
TC0–TC182) against the Reference Server. After these changes no test
case fails; the remaining non-pass verdicts are test-plan expectations
that contradict Part 12/Part 4/Part 20 (documented in the run report).

### Server behaviour changes

- **UpdateCertificate requires the issuer in the TrustList**
(`ConfigurationNodeManager.PushValidation.cs`). The validator classifies
an incomplete chain as suppressible; OPC 10000-4 Table 100 does not ("An
error during the chain creation may not be suppressed") and §7.10.5
requires the issuers to already be in the group's TrustList. A
certificate of an unknown CA is now rejected with
`Bad_CertificateChainIncomplete`. Expiry / host-name errors stay ignored
as §7.10.5 requires.
- **TrustList audit events reach the Server Object.**
`TrustListUpdateRequested`/`TrustListUpdated` were reported on the
TrustList node (`EventNotifier=0`, not linked to the Server), so no
client could receive them. `TrustList.SetAuditEventServer` routes them
through `IAuditEventServer`; wired for the Push TrustLists and the GDS
CertificateGroup TrustLists. New `AuditEvents` overloads keep the
node-based ones.
- **Duplicate AddCertificate** no longer makes the following
ApplyChanges fail with a bare `Bad` (store refused the second copy); the
commit is idempotent and its rollback keeps the pre-existing
certificate.
- **CloseAndUpdate validates CRLs** (incl. CRL-only updates): every CRL
must be signed by a CA of the new TrustList (§7.8.2.5). Previously an
unknown/forged CRL passed CloseAndUpdate and only failed ApplyChanges.
- **X.509 v1 certificates are rejected** by
CloseAndUpdate/AddCertificate/UpdateCertificate (OPC 10000-6 §6.2.2; new
`X509Utils.IsX509Version3`).
- **Application setup state (Annex G.2):** new
`StandardServer.StartupServerState` (virtual); the DI host returns
`NoConfiguration` when `ServerConfigurationOptions.InApplicationSetup ==
true`. `IsRunning` and `NodeManagerLifecycle` treat `NoConfiguration` as
operational. The Reference Server `--provision` mode starts in
`NoConfiguration` and exposes `InApplicationSetup=TRUE`.
- **DefaultHttpsGroup:** the group is only created when Https stores are
configured — the Reference Server config had none, so its
`opc.https`/`opc.wss` endpoints used the RSA application certificate
with an empty TLS trust list and the Https certificate could not be
pushed. The sample config now declares
`HttpsIssuerCertificates`/`TrustedHttpsCertificates` and an
`HttpsCertificateType` certificate (new `CertificateTypeString` value
`Https`). An Https certificate is assigned to DefaultHttpsGroup only (no
longer also listed in DefaultApplicationGroup). Fixed
`AddSecurityConfigurationHttpsStore`, which built the trusted Https
store path from the store *type*.
- Minor: AddCertificate logs a warning when it adds a CA without a CRL
while `RejectUnknownRevocationStatus` is enabled; UpdateCertificate
refused for an insufficient SecureChannel raises
`CertificateUpdateRequestedAuditEvent` with Status=false.

### Kept on purpose
- PEM certificates are accepted (converted to DER).
- `Bad_NotWritable` when opening an already open TrustList (OPC 10000-20
§4.2.2) and for AddCertificate while a read handle is open (§7.8.2.6).
- No validation rules for DefaultUserTokenGroup / DefaultHttpsGroup
TrustLists (Part 12 defines them only for ApplicationCertificateType
groups).

### Notes for reviewers
- Existing tests in `ConfigurationNodeManagerPushTests` and
`PushCertificateRequestRegressionTests` signed pushed certificates with
CAs that were not in the TrustList; they now trust their CA first
(behaviour change above).
- Adding the Https certificate to
`Quickstarts.ReferenceServer.Config.xml` gives the Reference Server's
opc.https/opc.wss endpoints their own TLS certificate.

### Tests
-
`tests/Opc.Ua.Server.Tests/Configuration/PushComplianceRegressionTests.cs`
— duplicate add/rollback, PEM, v1/v3, CRL issuer/forged/CRL-only, audit
routing.
- `tests/Opc.Ua.Server.Tests/ApplicationSetupServerStateTests.cs` —
NoConfiguration startup state.
- `tests/Opc.Ua.Gds.Tests/PushComplianceTests.cs` — end-to-end over a
secure channel: TrustList transaction lifecycle, open-handle rules,
masked reads, duplicate/PEM add, unknown CRL issuer, unknown certificate
issuer, cross-Session CSR key, nonce rules, RemoveCertificate chain/CRL
handling. Mutation-checked: reverting the issuer, duplicate-add and CRL
fixes makes the corresponding tests fail.
- Additional cases in `PushCertificateRequestRegressionTests`,
`CertificateIdentifierTests`, `ApplicationConfigurationBuilderTests`.

Local runs (net10.0): Opc.Ua.Gds.Tests 1257 passed / 0 failed / 56
skipped; Opc.Ua.Server.Tests push/TrustList/audit/lifecycle subset 1209
passed; Core and Configuration test classes passed. net48 build of the
affected test projects succeeds; `dotnet format whitespace` clean.

## Related Issues

- None (findings from a manual Push Model compliance run).

## Checklist

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [ ] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:48:02 +00:00

971 lines
43 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Gds.Server;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Server;
using Opc.Ua.Server.UserDatabase;
using Opc.Ua.Server.UserManagement;
using Quickstarts.Servers;
namespace Quickstarts.ReferenceServer
{
/// <summary>
/// Implements the Quickstart Reference Server.
/// </summary>
/// <remarks>
/// <para>
/// Each server instance must have one instance of a StandardServer object which is
/// responsible for reading the configuration file, creating the endpoints and dispatching
/// incoming requests to the appropriate handler.
/// </para>
/// <para>
/// This sub-class specifies non-configurable metadata such as Product Name and initializes
/// the EmptyNodeManager which provides access to the data exposed by the Server.
/// </para>
/// </remarks>
public class ReferenceServer : ReverseConnectServer
{
/// <summary>
/// Create reference server
/// </summary>
public ReferenceServer(ITelemetryContext telemetry)
: base(telemetry)
{
m_telemetry = telemetry;
m_userDatabase = new LinqUserDatabase();
m_userDatabase.CreateUser("sysadmin", "demo"u8, [Role.SecurityAdmin, Role.AuthenticatedUser]);
m_userDatabase.CreateUser("user1", "password"u8, [Role.AuthenticatedUser]);
m_userDatabase.CreateUser("user2", "password1"u8, [Role.AuthenticatedUser]);
m_userDatabase.CreateUser(
"SystemAdmin",
Encoding.UTF8.GetBytes("demo"),
[GdsRole.CertificateAuthorityAdmin, GdsRole.DiscoveryAdmin, Role.SecurityAdmin,
Role.ConfigureAdmin, Role.AuthenticatedUser]);
m_userDatabase.CreateUser(
"AppAdmin",
Encoding.UTF8.GetBytes("demo"),
[Role.AuthenticatedUser, GdsRole.CertificateAuthorityAdmin,
GdsRole.DiscoveryAdmin, Role.AuthenticatedUser]);
m_userDatabase.CreateUser(
"DiscoveryAdmin",
Encoding.UTF8.GetBytes("demo"),
[Role.AuthenticatedUser, GdsRole.DiscoveryAdmin, Role.AuthenticatedUser]);
m_userDatabase.CreateUser(
"CertificateAuthorityAdmin",
Encoding.UTF8.GetBytes("demo"),
[Role.AuthenticatedUser, GdsRole.CertificateAuthorityAdmin, Role.AuthenticatedUser]);
m_userManagement = new UserManagement(m_userDatabase, new Opc.Ua.Range(256, 1));
}
/// <summary>
/// Token validator
/// </summary>
public ITokenValidator? TokenValidator { get; set; }
/// <summary>
/// If true the ReferenceNodeManager is set to work with a sampling group mechanism
/// for managing monitored items instead of a Monitored Node mechanism
/// </summary>
public bool UseSamplingGroupsInReferenceNodeManager { get; set; }
/// <summary>
/// If true, the server starts in provisioning mode with limited namespace
/// and requires authenticated user access for certificate provisioning
/// </summary>
public bool ProvisioningMode { get; set; }
/// <summary>
/// The user database used for credential verification and user management.
/// </summary>
public IUserDatabase UserDatabase => m_userDatabase;
/// <summary>
/// If true, the server creates the FileSystem node manager that
/// exposes the configured <see cref="FileSystemProvider"/> under
/// the standard <c>Server.FileSystem</c> object (<c>i=16314</c>).
/// This materially grows the address space — only enable it in
/// tests / hosts that exercise FileSystem (Part 20). Default is
/// <c>false</c> so the standard test fixtures keep a small,
/// browse-friendly address space.
/// </summary>
public bool EnableFileSystemNodeManager { get; set; }
/// <summary>
/// Provider that backs the FileSystem node manager when
/// <see cref="EnableFileSystemNodeManager"/> is <c>true</c>.
/// When <c>null</c>, the server falls back to a
/// <see cref="Opc.Ua.Server.FileSystem.PhysicalFileSystemProvider"/>
/// rooted at <c>%TEMP%/OpcUaReferenceServerFs</c>.
/// </summary>
public Opc.Ua.Server.FileSystem.IFileSystemProvider? FileSystemProvider { get; set; }
/// <summary>
/// Creates the node managers for the server.
/// </summary>
/// <remarks>
/// This method allows the sub-class create any additional node managers which it uses. The SDK
/// always creates a CoreNodeManager which handles the built-in nodes defined by the specification.
/// Any additional NodeManagers are expected to handle application specific nodes.
/// </remarks>
protected override IMasterNodeManager CreateMasterNodeManager(
IServerInternal server,
ApplicationConfiguration configuration)
{
m_logger.CreatingReferenceServerNodeManager();
var nodeManagers = new List<INodeManager>();
var asyncNodeManagers = new List<IAsyncNodeManager>();
if (ProvisioningMode)
{
m_logger.ProvisioningModeEnabled();
nodeManagers = [];
}
else
{
ReferenceNodeManager? referenceNodeManager = null;
try
{
// CA2000: ownership-transfer pattern — nulled after handoff to asyncNodeManagers.
#pragma warning disable CA2000
referenceNodeManager = new ReferenceNodeManager(
server,
configuration,
UseSamplingGroupsInReferenceNodeManager);
#pragma warning restore CA2000
asyncNodeManagers = [referenceNodeManager];
referenceNodeManager = null;
}
finally
{
// CA1508: only non-null on the exceptional path (try block clears it on success).
#pragma warning disable CA1508
referenceNodeManager?.Dispose();
#pragma warning restore CA1508
}
foreach (INodeManagerFactory nodeManagerFactory in NodeManagerFactories)
{
nodeManagers.Add(nodeManagerFactory.Create(server, configuration));
}
foreach (IAsyncNodeManagerFactory nodeManagerFactory in AsyncNodeManagerFactories)
{
asyncNodeManagers.Add(nodeManagerFactory.CreateAsync(server, configuration).AsTask().GetAwaiter().GetResult());
}
}
if (EnableFileSystemNodeManager)
{
// FileSystem node manager — exposes the configured
// provider (defaults to a temp folder) under the standard
// Server.FileSystem object (i=16314). The reference server is a
// test server, so anonymous clients may modify the mount too.
Opc.Ua.Server.FileSystem.IFileSystemProvider provider =
FileSystemProvider ?? CreateDefaultFileSystemProvider();
asyncNodeManagers.Add(new Opc.Ua.Server.FileSystem.FileSystemNodeManager(
server, configuration, provider)
{
AllowAnonymousWrite = true
});
}
// OPC UA Part 17 — AliasName provider for the reference server.
//
// Registers a Part 17 InMemoryAliasNameStore directly with the
// server-wide IAliasNameStoreRegistry so the standard
// well-known FindAlias methods on TagVariables (i=23485) and
// Topics (i=23494) — wired by DiagnosticsNodeManager — return
// the sample tag/topic aliases below.
//
// No standalone AliasNameNodeManager is needed for read-only
// service of standard categories; apps wanting their own
// categories with full Add/Delete support add an
// AliasNameNodeManager configured with their own namespace.
ConfigureAliasNameStore(server);
return new MasterNodeManager(server, configuration, null, asyncNodeManagers, nodeManagers);
}
/// <inheritdoc/>
protected override Opc.Ua.Server.UserManagement.IUserManagement CreateUserManagement(
IServerInternal server,
ApplicationConfiguration configuration)
{
return m_userManagement;
}
private static void ConfigureAliasNameStore(IServerInternal server)
{
if (server is not Opc.Ua.Server.AliasNames.IAliasNameStoreRegistryProvider provider)
{
return;
}
// A nested category under TagVariables: Part 17 §6.3.1 allows
// AliasNameCategoryType instances to be organized under one
// another, and FindAlias on the parent searches the whole
// sub-tree while FindAlias on the child is restricted to it.
// Its NodeId lives in the diagnostics namespace because that is
// where DiagnosticsNodeManager materializes the node — which
// means the namespace is registered here, ahead of the node
// manager that would otherwise be the first to add it.
// BrowseName and NodeId share the diagnostics namespace:
// namespace 0 is reserved for OPC-Foundation-defined names, so
// a server-defined category (and the aliases that inherit its
// BrowseName namespace via the store) must not publish there.
ushort diagnosticsNamespaceIndex =
server.NamespaceUris.GetIndexOrAppend(DiagnosticsNamespaceUri);
var devices = new Opc.Ua.Server.AliasNames.AliasNameCategoryDescriptor(
new NodeId(DevicesCategoryName, diagnosticsNamespaceIndex),
new QualifiedName(DevicesCategoryName, diagnosticsNamespaceIndex),
Opc.Ua.Server.AliasNames.AliasNameCapabilities.All);
// The descriptor BrowseName's namespace is what the store
// stamps onto every alias QualifiedName it reports, and the
// materializer re-homes ns=0 alias BrowseNames into the
// diagnostics namespace — declaring the diagnostics namespace
// here keeps FindAlias results and the browsable nodes'
// BrowseNames identical, so TranslateBrowsePaths with a
// returned alias name resolves. (The predefined TagVariables/
// Topics nodes keep their own NodeSet BrowseNames regardless.)
var tagVariables = new Opc.Ua.Server.AliasNames.AliasNameCategoryDescriptor(
Opc.Ua.ObjectIds.TagVariables,
new QualifiedName(Opc.Ua.BrowseNames.TagVariables, diagnosticsNamespaceIndex),
Opc.Ua.Server.AliasNames.AliasNameCapabilities.All,
subCategories: [devices]);
var topics = new Opc.Ua.Server.AliasNames.AliasNameCategoryDescriptor(
Opc.Ua.ObjectIds.Topics,
new QualifiedName(Opc.Ua.BrowseNames.Topics, diagnosticsNamespaceIndex),
Opc.Ua.Server.AliasNames.AliasNameCapabilities.All);
// Root the Aliases (i=23470) object too so FindAlias /
// FindAliasVerbose / LastChange dispatched against it
// aggregate the TagVariables / Topics sub-categories
// (per Part 17 §6.3.2 recursive matching semantics).
//
// Every category declares the full capability set: the standard
// NodeSet instantiates none of the optional Part 17 children on
// the well-known objects, so DiagnosticsNodeManager adds them
// during materialization.
// AddAliasesToCategory / DeleteAliasesFromCategory require a
// SecurityAdmin caller on a SignAndEncrypt channel.
var aliases = new Opc.Ua.Server.AliasNames.AliasNameCategoryDescriptor(
Opc.Ua.ObjectIds.Aliases,
new QualifiedName(Opc.Ua.BrowseNames.Aliases, diagnosticsNamespaceIndex),
Opc.Ua.Server.AliasNames.AliasNameCapabilities.All,
subCategories: [tagVariables, topics]);
// CA2000: ownership transferred to the registry which disposes
// the store along with itself.
#pragma warning disable CA2000
var store = new Opc.Ua.Server.AliasNames.InMemoryAliasNameStore(
[aliases]);
#pragma warning restore CA2000
int refServerNsIndex = server.NamespaceUris.GetIndex(
Namespaces.ReferenceServer);
ushort refServerNs = refServerNsIndex >= 0
? (ushort)refServerNsIndex
: ushort.MaxValue;
if (refServerNs != ushort.MaxValue)
{
SeedTag(store, Opc.Ua.ObjectIds.TagVariables, "TIC101_Setpoint",
new ExpandedNodeId("Scalar_Static_Double", refServerNs));
SeedTag(store, Opc.Ua.ObjectIds.TagVariables, "TIC101_PV",
new ExpandedNodeId("Scalar_Static_Float", refServerNs));
SeedTag(store, Opc.Ua.ObjectIds.TagVariables, "FIC202_Flow",
new ExpandedNodeId("Scalar_Simulation_Double", refServerNs));
SeedTag(store, Opc.Ua.ObjectIds.TagVariables, "MultiRefAlias",
new ExpandedNodeId("Scalar_Static_Double", refServerNs));
SeedTag(store, Opc.Ua.ObjectIds.TagVariables, "MultiRefAlias",
new ExpandedNodeId("Scalar_Static_Int32", refServerNs));
// The device tags live in the nested category, so FindAlias
// on Devices returns exactly these two while FindAlias on
// TagVariables still returns all six.
SeedTag(store, devices.NodeId, "Pump1_Status",
new ExpandedNodeId("Scalar_Static_Boolean", refServerNs));
SeedTag(store, devices.NodeId, "Heater_Power",
new ExpandedNodeId("Scalar_Static_Int32", refServerNs));
}
// The Topics aliases are NOT seeded here: Part 17 §9 constrains
// that hierarchy to aliases for PublishedDataSetType instances,
// and those dataset nodes are created later by
// ReferenceServerConfigurationNodeManager.AddConformanceDataSetsAsync
// — which seeds the aliases in the same place, so an alias can
// never point at a dataset that was not created.
provider.AliasNameStoreRegistry.Register(store);
static void SeedTag(
Opc.Ua.Server.AliasNames.InMemoryAliasNameStore store,
NodeId categoryId,
string name,
ExpandedNodeId target)
{
store.Seed(categoryId, name, target,
serverUri: null,
referenceTypeId: ReferenceTypeIds.AliasFor);
}
}
/// <summary>
/// BrowseName of the AliasNameCategory nested under TagVariables.
/// </summary>
private const string DevicesCategoryName = "Devices";
/// <summary>
/// Namespace that <c>DiagnosticsNodeManager</c> owns and in which
/// the materialized alias nodes and the conformance datasets live.
/// Shared with <see cref="ReferenceServerConfigurationNodeManager"/>
/// so the dataset NodeIds and the alias seeds that target them can
/// never drift apart.
/// </summary>
internal const string DiagnosticsNamespaceUri = Opc.Ua.Namespaces.OpcUa + "Diagnostics";
/// <summary>
/// Overrides the SDK default factory to plug in a
/// reference-server-specific <see cref="ConfigurationNodeManager"/>
/// that applies a few CTT-only address-space tweaks (Server-node
/// RolePermissions, HasAddIn instance, optional EngineeringUnits
/// on AnalogItemType). Keeping these out of the SDK avoids
/// polluting the standard nodeset for non-CTT hosts.
/// </summary>
protected override IMainNodeManagerFactory CreateMainNodeManagerFactory(
IServerInternal server,
ApplicationConfiguration configuration)
{
// In provisioning mode the server is in the application setup
// state (OPC 10000-12 Annex G.2): expose InApplicationSetup=TRUE.
return new ReferenceServerMainNodeManagerFactory(
configuration,
server,
ProvisioningMode ? new ServerConfigurationOptions { InApplicationSetup = true } : null);
}
/// <inheritdoc/>
/// <remarks>
/// OPC 10000-12 Annex G.2: while the server is in the application
/// setup state (provisioning mode) its ServerState is NoConfiguration.
/// </remarks>
protected override ServerState StartupServerState =>
ProvisioningMode ? ServerState.NoConfiguration : base.StartupServerState;
/// <summary>
/// Returns a default <see cref="Opc.Ua.Server.FileSystem.PhysicalFileSystemProvider"/>
/// rooted at a per-process temp folder. Override
/// <see cref="FileSystemProvider"/> to mount a different backend.
/// </summary>
private static Opc.Ua.Server.FileSystem.PhysicalFileSystemProvider CreateDefaultFileSystemProvider()
{
string root = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
"OpcUaReferenceServerFs");
return new Opc.Ua.Server.FileSystem.PhysicalFileSystemProvider(
root,
mountName: "Temp");
}
protected override IMonitoredItemQueueFactory CreateMonitoredItemQueueFactory(
IServerInternal server,
ApplicationConfiguration configuration)
{
if (configuration?.ServerConfiguration?.DurableSubscriptionsEnabled == true)
{
return new Servers.DurableMonitoredItemQueueFactory(server.Telemetry, server.MessageContext);
}
return new MonitoredItemQueueFactory(server.Telemetry);
}
/// <summary>
/// Creates the subscriptionStore for the server.
/// </summary>
/// <param name="server">The server.</param>
/// <param name="configuration">The configuration.</param>
/// <returns>Returns a subscriptionStore for a server, the return type is <seealso cref="ISubscriptionStore"/>.</returns>
protected override ISubscriptionStore? CreateSubscriptionStore(
IServerInternal server,
ApplicationConfiguration configuration)
{
if (configuration?.ServerConfiguration?.DurableSubscriptionsEnabled == true)
{
return new Servers.SubscriptionStore(server);
}
return null;
}
/// <summary>
/// Loads the non-configurable properties for the application.
/// </summary>
/// <remarks>
/// These properties are exposed by the server but cannot be changed by administrators.
/// </remarks>
protected override ServerProperties LoadServerProperties()
{
return new ServerProperties
{
ManufacturerName = "OPC Foundation",
ProductName = "Quickstart Reference Server",
ProductUri = "http://opcfoundation.org/Quickstart/ReferenceServer/v1.04",
SoftwareVersion = Opc.Ua.Utils.GetAssemblySoftwareVersion(),
BuildNumber = Opc.Ua.Utils.GetAssemblyBuildNumber(),
BuildDate = Opc.Ua.Utils.GetAssemblyTimestamp()
};
}
/// <summary>
/// Creates the resource manager for the server.
/// </summary>
protected override ResourceManager CreateResourceManager(
IServerInternal server,
ApplicationConfiguration configuration)
{
var resourceManager = new ResourceManager(configuration);
foreach (StatusCode id in StatusCode.InternedStatusCodes)
{
if (id.SymbolicId is { } symbolicId)
{
resourceManager.Add(symbolicId, "en-US", symbolicId);
// Advertise German ("de-DE") as an additional supported locale.
// The server's ServerCapabilities.LocaleIdArray is derived from
// the locales that have translation tables, so registering the
// status-code symbols under de-DE makes the locale available.
resourceManager.Add(symbolicId, "de-DE", symbolicId);
}
}
// Offer the application name in the advertised locales, so FindServers and
// GetEndpoints return a de-DE ApplicationName when the client asks for German.
string applicationName = configuration.ApplicationName ?? string.Empty;
resourceManager.Add(ApplicationNameKey, "en-US", applicationName);
resourceManager.Add(
ApplicationNameKey,
"de-DE",
applicationName.Replace("Reference Server", "Referenzserver", StringComparison.Ordinal));
return resourceManager;
}
/// <summary>
/// Initializes the server before it starts up.
/// </summary>
/// <remarks>
/// This method is called before any startup processing occurs. The sub-class may update the
/// configuration object or do any other application specific startup tasks.
/// </remarks>
protected override void OnServerStarting(ApplicationConfiguration configuration)
{
base.OnServerStarting(configuration);
m_logger.ServerStarting();
// it is up to the application to decide how to validate user identity tokens.
// this function creates validator for X509 identity tokens.
CreateUserIdentityValidators(configuration);
}
/// <summary>
/// Called after the server has been started.
/// </summary>
protected override void OnServerStarted(IServerInternal server)
{
base.OnServerStarted(server);
RegisterIdentityAuthenticators(server);
// Key the ApplicationName of the endpoints, so the resource manager translates it.
if (ServerDescription != null)
{
ServerDescription.ApplicationName = new LocalizedText(
ApplicationNameKey,
"en-US",
ServerDescription.ApplicationName.Text ?? string.Empty);
}
try
{
ServerInternal.UpdateServerStatus(
status =>
// allow a faster sampling interval for CurrentTime node.
status.Variable!.CurrentTime!.MinimumSamplingInterval = 250);
}
catch
{
}
}
/// <summary>
/// Override some of the default user token policies for some endpoints.
/// </summary>
/// <remarks>
/// Sample to show how to override default user token policies.
/// </remarks>
public override ArrayOf<UserTokenPolicy> GetUserTokenPolicies(
ApplicationConfiguration configuration,
EndpointDescription description)
{
ArrayOf<UserTokenPolicy> policies = base.GetUserTokenPolicies(
configuration,
description);
// In provisioning mode, remove anonymous authentication
if (ProvisioningMode)
{
return policies.Filter(u => u.TokenType != UserTokenType.Anonymous);
}
// sample how to modify default user token policies
if (description.SecurityPolicyUri == SecurityPolicies.Aes256_Sha256_RsaPss &&
description.SecurityMode == MessageSecurityMode.SignAndEncrypt)
{
return policies.Filter(u => u.TokenType != UserTokenType.Certificate);
}
else if (description.SecurityPolicyUri == SecurityPolicies.Aes128_Sha256_RsaOaep &&
description.SecurityMode == MessageSecurityMode.Sign)
{
return policies.Filter(u => u.TokenType != UserTokenType.Anonymous);
}
else if (description.SecurityPolicyUri == SecurityPolicies.Aes128_Sha256_RsaOaep &&
description.SecurityMode == MessageSecurityMode.SignAndEncrypt)
{
return policies.Filter(u => u.TokenType != UserTokenType.UserName);
}
return policies;
}
/// <summary>
/// Creates the objects used to validate the user identity tokens supported by the server.
/// </summary>
private void CreateUserIdentityValidators(ApplicationConfiguration configuration)
{
ServerConfiguration serverConfiguration = configuration.ServerConfiguration!;
for (int ii = 0; ii < serverConfiguration.UserTokenPolicies.Count; ii++)
{
UserTokenPolicy policy = serverConfiguration.UserTokenPolicies[ii];
// create a validator for a certificate token policy.
if (policy.TokenType == UserTokenType.Certificate)
{
// check if user certificate trust lists are specified in configuration.
if (configuration.SecurityConfiguration.TrustedUserCertificates != null &&
configuration.SecurityConfiguration.UserIssuerCertificates != null)
{
// The server's CertificateManager already maps
// TrustedUserCertificates / UserIssuerCertificates to the
// Users trust list during MapFromSecurityConfiguration().
// Use the CertificateManager directly and validate against
// the Users trust list per call.
m_userCertificateValidator = CertificateManager;
// Configure a dedicated review store for rejected X509 user
// certificates so an operator can inspect a rejected user
// certificate and, if legitimate, move it into the
// TrustedUserCertificates store (the shared rejected store
// mixes application and user certificates, which is awkward
// when provisioning user identities for conformance testing).
ConfigureRejectedUserCertificateStore(configuration.SecurityConfiguration);
}
}
}
}
/// <summary>
/// Resolves the directory used to persist rejected X509 <b>user</b>
/// certificates for later review. It is created as a sibling of the
/// configured <c>TrustedUserCertificates</c> store (e.g.
/// <c>pki/rejectedUser</c> next to <c>pki/trustedUser</c>). Failures are
/// swallowed: an unavailable review store must never affect user
/// authentication.
/// </summary>
private void ConfigureRejectedUserCertificateStore(SecurityConfiguration security)
{
try
{
string? trustedUserPath = Opc.Ua.Utils.ReplaceSpecialFolderNames(
security.TrustedUserCertificates?.StorePath);
if (string.IsNullOrEmpty(trustedUserPath))
{
return;
}
string? parent = Path.GetDirectoryName(
trustedUserPath!.TrimEnd(
Path.DirectorySeparatorChar,
Path.AltDirectorySeparatorChar));
if (!string.IsNullOrEmpty(parent))
{
m_rejectedUserStorePath = Path.Combine(parent!, "rejectedUser");
}
}
catch (Exception e)
{
m_logger.FailedToConfigureRejectedUserStore(e);
}
}
/// <summary>
/// Validates the password for a username token.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
private RoleBasedIdentity VerifyPassword(UserNameIdentityTokenHandler userTokenHandler)
{
string userName = userTokenHandler.UserName;
byte[]? password = userTokenHandler.DecryptedPassword;
if (string.IsNullOrEmpty(userName))
{
// an empty username is not accepted.
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
"Security token is not a valid username token. An empty username is not accepted.");
}
if (Opc.Ua.Utils.Utf8IsNullOrEmpty(password))
{
// an empty password is not accepted.
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenRejected,
"Security token is not a valid username token. An empty password is not accepted.");
}
if (!m_userDatabase.CheckCredentials(userName, password))
{
// construct translation object with default text.
var info = new TranslationInfo(
"InvalidPassword",
"en-US",
"Invalid username or password.",
userName);
// create an exception with a vendor defined sub-code.
throw new ServiceResultException(
new ServiceResult(
LoadServerProperties().ProductUri,
new StatusCode(StatusCodes.BadUserAccessDenied.Code, "InvalidPassword"),
new LocalizedText(info)));
}
ICollection<Role> roles = m_userDatabase.GetUserRoles(userName);
var identity = new UserIdentity(userTokenHandler);
List<Role> effectiveRoles = roles is { Count: > 0 } ? [.. roles] : [Role.AuthenticatedUser];
if (effectiveRoles.Contains(Role.SecurityAdmin) && !effectiveRoles.Contains(Role.ConfigureAdmin))
{
effectiveRoles.Add(Role.ConfigureAdmin);
}
return new RoleBasedIdentity(
identity,
effectiveRoles,
ServerInternal.MessageContext.NamespaceUris);
}
/// <summary>
/// Verifies that a certificate user token is trusted.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
private async ValueTask VerifyX509IdentityTokenAsync(
X509IdentityTokenHandler x509TokenHandler,
CancellationToken ct)
{
var wireToken = (X509IdentityToken)x509TokenHandler.Token;
using Certificate? userCertificate = wireToken.CertificateData.IsEmpty
? null
: Certificate.FromRawData(wireToken.CertificateData);
try
{
Opc.Ua.CertificateValidationResult userCertResult =
await (m_userCertificateValidator ?? CertificateManager!)
.ValidateAsync(
userCertificate!,
TrustListIdentifier.Users,
ct)
.ConfigureAwait(false);
if (!userCertResult.IsValid)
{
throw new ServiceResultException(userCertResult.StatusCode);
}
}
catch (Exception e)
{
// Persist the rejected user certificate to a dedicated review
// store so an operator can inspect it and, if legitimate, move it
// into the TrustedUserCertificates store. Best-effort: a review
// store write must never change the activation result.
await PersistRejectedUserCertificateAsync(userCertificate, ct)
.ConfigureAwait(false);
TranslationInfo info;
StatusCode result = StatusCodes.BadIdentityTokenRejected;
if (e is ServiceResultException se &&
se.StatusCode == StatusCodes.BadCertificateUseNotAllowed)
{
info = new TranslationInfo(
"InvalidCertificate",
"en-US",
"'{0}' is an invalid user certificate.",
userCertificate?.Subject ?? string.Empty);
result = StatusCodes.BadIdentityTokenInvalid;
}
else
{
// construct translation object with default text.
info = new TranslationInfo(
"UntrustedCertificate",
"en-US",
"'{0}' is not a trusted user certificate.",
userCertificate?.Subject ?? string.Empty);
}
// create an exception with a vendor defined sub-code.
throw new ServiceResultException(
new ServiceResult(
LoadServerProperties().ProductUri,
new StatusCode(result.Code, info.Key),
new LocalizedText(info)));
}
}
/// <summary>
/// Best-effort persistence of a rejected X509 user certificate to the
/// dedicated review store configured by
/// <see cref="ConfigureRejectedUserCertificateStore"/>. Never throws:
/// any failure is logged and swallowed so it cannot influence the
/// authentication outcome.
/// </summary>
private async ValueTask PersistRejectedUserCertificateAsync(
Certificate? userCertificate,
CancellationToken ct)
{
if (userCertificate == null || string.IsNullOrEmpty(m_rejectedUserStorePath))
{
return;
}
try
{
await userCertificate.AddToStoreAsync(
CertificateStoreType.Directory,
m_rejectedUserStorePath!,
password: null,
m_telemetry,
ct).ConfigureAwait(false);
}
catch (Exception e)
{
m_logger.FailedToPersistRejectedUserCertificate(e, m_rejectedUserStorePath);
}
}
private IUserIdentity? VerifyIssuedToken(IssuedIdentityTokenHandler issuedTokenHandler)
{
if (TokenValidator == null)
{
m_logger.NoTokenValidatorSpecified();
return null;
}
try
{
if (issuedTokenHandler.IssuedTokenType == IssuedTokenType.JWT)
{
m_logger.VerifyIssuedTokenValidateToken();
return TokenValidator.ValidateToken(issuedTokenHandler);
}
return null;
}
catch (Exception e)
{
TranslationInfo info;
StatusCode result = StatusCodes.BadIdentityTokenRejected;
if (e is ServiceResultException se &&
se.StatusCode == StatusCodes.BadIdentityTokenInvalid)
{
info = new TranslationInfo(
"IssuedTokenInvalid",
"en-US",
"token is an invalid issued token.");
result = StatusCodes.BadIdentityTokenInvalid;
}
else // Rejected
{
// construct translation object with default text.
info = new TranslationInfo(
"IssuedTokenRejected",
"en-US",
"token is rejected.");
}
m_logger.VerifyIssuedTokenThrowServiceResultException(result);
throw new ServiceResultException(
new ServiceResult(
LoadServerProperties().ProductUri,
new StatusCode(result.Code, info.Key),
new LocalizedText(info)));
}
}
private void RegisterIdentityAuthenticators(IServerInternal server)
{
server.IdentityRegistry.RegisterDefaultAuthenticators(
VerifyUserNameIdentityAsync,
VerifyX509IdentityAsync,
VerifyJwtIdentityAsync);
}
private ValueTask<IUserIdentity> VerifyUserNameIdentityAsync(
UserNameIdentityTokenHandler userNameToken,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
RoleBasedIdentity identity = VerifyPassword(userNameToken);
m_logger.UsernameTokenAccepted(identity.DisplayName);
return new ValueTask<IUserIdentity>(identity);
}
private async ValueTask<IUserIdentity> VerifyX509IdentityAsync(
X509IdentityTokenHandler x509Token,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
await VerifyX509IdentityTokenAsync(x509Token, ct).ConfigureAwait(false);
var identity = new RoleBasedIdentity(
new UserIdentity(x509Token),
[Role.AuthenticatedUser],
ServerInternal.MessageContext.NamespaceUris);
m_logger.X509TokenAccepted(identity.DisplayName);
return identity;
}
private ValueTask<IUserIdentity?> VerifyJwtIdentityAsync(
IssuedIdentityTokenHandler issuedToken,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
IUserIdentity? identity = VerifyIssuedToken(issuedToken);
return new ValueTask<IUserIdentity?>(identity == null
? null
: new RoleBasedIdentity(
identity,
[Role.AuthenticatedUser],
ServerInternal.MessageContext.NamespaceUris));
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
m_userManagement.Dispose();
}
base.Dispose(disposing);
}
private const string ApplicationNameKey = "ApplicationName";
private CertificateManager? m_userCertificateValidator;
private string? m_rejectedUserStorePath;
private readonly ITelemetryContext m_telemetry;
private readonly LinqUserDatabase m_userDatabase;
private readonly UserManagement m_userManagement;
}
internal static partial class ReferenceServerLog
{
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 0, Level = LogLevel.Information,
Message = "Creating the Reference Server Node Manager.")]
public static partial void CreatingReferenceServerNodeManager(this ILogger logger);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 1, Level = LogLevel.Information,
Message = "Server is in provisioning mode - limited namespace enabled.")]
public static partial void ProvisioningModeEnabled(this ILogger logger);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 2, Level = LogLevel.Information,
Message = "The server is starting.")]
public static partial void ServerStarting(this ILogger logger);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 3, Level = LogLevel.Warning,
Message = "No TokenValidator is specified.")]
public static partial void NoTokenValidatorSpecified(this ILogger logger);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 4, Level = LogLevel.Debug,
Message = "VerifyIssuedToken: ValidateToken")]
public static partial void VerifyIssuedTokenValidateToken(this ILogger logger);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 5, Level = LogLevel.Warning,
Message = "VerifyIssuedToken: Throw ServiceResultException 0x{Result:x}")]
public static partial void VerifyIssuedTokenThrowServiceResultException(
this ILogger logger,
StatusCode result);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 6, Level = LogLevel.Information,
Message = "Username Token Accepted: {Identity}")]
public static partial void UsernameTokenAccepted(this ILogger logger, string identity);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 7, Level = LogLevel.Information,
Message = "X509 Token Accepted: {Identity}")]
public static partial void X509TokenAccepted(this ILogger logger, string identity);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 8, Level = LogLevel.Warning,
Message = "Failed to configure the rejected user certificate review store.")]
public static partial void FailedToConfigureRejectedUserStore(this ILogger logger, Exception exception);
[LoggerMessage(
EventId = QuickstartsServersEventIds.ReferenceServer + 9, Level = LogLevel.Warning,
Message = "Failed to persist rejected user certificate to the review store '{Path}'.")]
public static partial void FailedToPersistRejectedUserCertificate(
this ILogger logger,
Exception exception,
string? path);
}
}