Files
Marc Schier dbc09b8e22 Adopt System.TimeProvider across the stack (fixes #2458) (#3829)
# Description

Adopt
[`System.TimeProvider`](https://learn.microsoft.com/dotnet/api/system.timeprovider)
across the stack so that every place that creates timers, runs duration
math, or schedules periodic work is mockable, deterministic in tests,
and immune to wall-clock changes.

The migration touches Core, Core.Types, Server, Client, PubSub,
Configuration, Gds (server + client), Lds, and the Quickstarts/Console
reference apps. Per-component changes follow the same pattern:

1. Add a `private readonly TimeProvider m_timeProvider;` field.
2. Accept a nullable `TimeProvider? timeProvider = null` as the **last**
constructor parameter (new overload; legacy constructor stays for binary
compatibility and delegates with `timeProvider: null`).
3. Initialise the field with `m_timeProvider = timeProvider ??=
TimeProvider.System;`.
4. Replace `new Timer(...)`, `Stopwatch`, `HiResClock.*`,
`DateTime.UtcNow`-based duration math, and `Task.Delay(delay, ct)` in
timing loops with the matching `m_timeProvider` API (`CreateTimer`,
`GetTimestamp` / `GetElapsedTime`, `GetUtcNow().UtcDateTime`,
`Task.Delay(delay, m_timeProvider, ct)`).
5. Wire the provider through DI: `AddOpcUaServerBuilder` /
`AddOpcUaClientBuilder` register `TimeProvider.System` via
`TryAddSingleton<TimeProvider>()` so tests can swap in
`Microsoft.Extensions.Time.Testing.FakeTimeProvider`.

`HiResClock` itself is kept (so the
`ApplicationConfiguration.DisableHiResClock` schema flag still
round-trips), but **every public member** is now `[Obsolete]` with a
single shared message pointing to the new migration-guide section. The
few internal call sites that still need to set/read `HiResClock` are
wrapped with `#pragma warning disable CS0618`.

### Highlights per component

- **Core / Stack** — `Opc.Ua.Core`, `Opc.Ua.Core.Types`, channel
pipeline (`UaSCBinaryClientChannel`, `ChannelToken`,
`UaSCBinaryChannel`), `ApplicationConfiguration`, certificate
validators, `JwksIssuerKeyResolver`, `TimeProviderExtensions` polyfills
(`GetTimestampMilliseconds`, `GetTickCount`,
`CancellationTokenSource(timeout, timeProvider)`, `Task.Delay(delay,
provider, ct)` polyfill for older TFMs).
- **Server** — session lockout, subscription pipeline
(publish/sample/keep-alive/lifetime), `MonitoredItemQueue`, alarm
sampling rate, `IntervalRunner`, server diagnostics, configuration state
machines, hosted services.
- **Client** — `Session`, managed/V2 subscription engines, reconnect
handlers, channel keep-alive, periodic publishing, complex-type
discovery polling.
- **PubSub** — `IntervalRunner` pipeline, message-receive timers,
MQTT/UDP keep-alive, publishers/readers.
- **Gds / Lds** — `LdsServer`, `LinqApplicationsDatabase` expiry
housekeeping, GDS clients (push, pull), `DirectoryCertificateStore`,
`ConnectionStateMachine`, classic subscription engine.
- **Quickstarts / Console samples** — `SampleNodeManager`,
`ReferenceNodeManager`, `AlarmNodeManager`, `BoilerState`,
`MemoryBufferState`, `PublishedValuesWrites`. Samples resolve via
`(Server as ITimeProviderProvider)?.TimeProvider ?? TimeProvider.System`
so existing ctor signatures stay binary-compatible.
- **Tests** — `Opc.Ua.PubSub.Tests` adds `IntervalRunnerTests`; existing
`SubscriptionTests`, `UaPublisherTests`, and `TestDataSystem` move off
`HiResClock`. Added `Microsoft.Extensions.TimeProvider.Testing` package
for `FakeTimeProvider` (project-load warning surfaces on net48/net472
only — runtime is .NET 8+).

### Migration guide

New section [**Time and Timer abstraction
(`TimeProvider`)**](Docs/MigrationGuide.md#time-and-timer-abstraction-timeprovider)
documents:

- The canonical replacement table (`HiResClock.UtcNow` →
`timeProvider.GetUtcNow().UtcDateTime`, `HiResClock.TickCount64` →
`timeProvider.GetTimestamp()`, `new Timer` → `timeProvider.CreateTimer`,
`Task.Delay(delay, ct)` → `Task.Delay(delay, timeProvider, ct)`, …).
- The `TimeProvider? timeProvider = null` + `??=` constructor pattern.
- DI wiring and how to swap in `FakeTimeProvider` in tests.
- Before/after snippets for `HiResClock.TickCount64`,
`HiResClock.UtcNow`, and `new Timer(...)`.

## Related Issues

- Fixes #2458

## 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.
- [x] 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.
2026-06-03 22:38:32 +02:00

1423 lines
56 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.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Server
{
/// <summary>
/// A generic session manager object for a server.
/// </summary>
public class Session : ISession
{
/// <summary>
/// Initializes a new instance of the <see cref="Session"/> class.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="server">The Server object.</param>
/// <param name="serverCertificate">The server certificate.</param>
/// <param name="authenticationToken">The unique private identifier assigned to the Session.</param>
/// <param name="clientNonce">The client nonce.</param>
/// <param name="serverNonce">The server nonce.</param>
/// <param name="sessionName">The name assigned to the Session.</param>
/// <param name="clientDescription">Application description for the client application.</param>
/// <param name="endpointUrl">The endpoint URL.</param>
/// <param name="clientCertificate">The client certificate.</param>
/// <param name="clientCertificateChain">The client certifiate chain</param>
/// <param name="sessionTimeout">The session timeout.</param>
/// <param name="maxBrowseContinuationPoints">The maximum number of browse continuation points.</param>
/// <param name="maxHistoryContinuationPoints">The maximum number of history continuation points.</param>
public Session(
OperationContext context,
IServerInternal server,
Certificate serverCertificate,
NodeId authenticationToken,
ByteString clientNonce,
Nonce serverNonce,
string sessionName,
ApplicationDescription clientDescription,
string endpointUrl,
Certificate clientCertificate,
CertificateCollection clientCertificateChain,
double sessionTimeout,
int maxBrowseContinuationPoints,
int maxHistoryContinuationPoints)
: this(
context,
server,
serverCertificate,
authenticationToken,
clientNonce,
serverNonce,
sessionName,
clientDescription,
endpointUrl,
clientCertificate,
clientCertificateChain,
sessionTimeout,
maxBrowseContinuationPoints,
maxHistoryContinuationPoints,
timeProvider: null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="Session"/> class with an
/// explicit <see cref="TimeProvider"/>.
/// </summary>
/// <param name="context">The context.</param>
/// <param name="server">The Server object.</param>
/// <param name="serverCertificate">The server certificate.</param>
/// <param name="authenticationToken">The unique private identifier assigned to the Session.</param>
/// <param name="clientNonce">The client nonce.</param>
/// <param name="serverNonce">The server nonce.</param>
/// <param name="sessionName">The name assigned to the Session.</param>
/// <param name="clientDescription">Application description for the client application.</param>
/// <param name="endpointUrl">The endpoint URL.</param>
/// <param name="clientCertificate">The client certificate.</param>
/// <param name="clientCertificateChain">The client certifiate chain</param>
/// <param name="sessionTimeout">The session timeout.</param>
/// <param name="maxBrowseContinuationPoints">The maximum number of browse continuation points.</param>
/// <param name="maxHistoryContinuationPoints">The maximum number of history continuation points.</param>
/// <param name="timeProvider">
/// Optional <see cref="TimeProvider"/> used for monotonic timeout
/// calculations and last-contact diagnostics. When <c>null</c>, the
/// time provider exposed by the server (via
/// <see cref="ITimeProviderProvider"/>) is used, falling back to
/// <see cref="TimeProvider.System"/>.
/// </param>
public Session(
OperationContext context,
IServerInternal server,
Certificate serverCertificate,
NodeId authenticationToken,
ByteString clientNonce,
Nonce serverNonce,
string sessionName,
ApplicationDescription clientDescription,
string endpointUrl,
Certificate clientCertificate,
CertificateCollection clientCertificateChain,
double sessionTimeout,
int maxBrowseContinuationPoints,
int maxHistoryContinuationPoints,
TimeProvider? timeProvider)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
// verify that a secure channel was specified.
if (context.ChannelContext == null)
{
throw new ServiceResultException(StatusCodes.BadSecureChannelIdInvalid);
}
m_server = server ?? throw new ArgumentNullException(nameof(server));
m_timeProvider = timeProvider
?? (server as ITimeProviderProvider)?.TimeProvider
?? TimeProvider.System;
m_logger = server.Telemetry.CreateLogger<Session>();
ClientNonce = clientNonce;
m_serverNonce = serverNonce;
m_sessionName = sessionName;
m_serverCertificate = serverCertificate;
ClientCertificate = clientCertificate;
m_clientIssuerCertificates = clientCertificateChain;
SecureChannelId = context.ChannelContext.SecureChannelId;
MaxBrowseContinuationPoints = maxBrowseContinuationPoints;
m_maxHistoryContinuationPoints = maxHistoryContinuationPoints;
EndpointDescription = context.ChannelContext.EndpointDescription!;
// use anonymous the default identity.
Identity = new UserIdentity();
// initialize diagnostics.
DateTime now = m_timeProvider.GetUtcNow().UtcDateTime;
m_lastContactTickCount = m_timeProvider.GetTimestampMilliseconds();
SessionDiagnostics = new SessionDiagnosticsDataType
{
SessionId = default,
SessionName = sessionName,
ClientDescription = clientDescription,
ServerUri = null,
EndpointUrl = endpointUrl,
ActualSessionTimeout = sessionTimeout,
ClientConnectionTime = now,
ClientLastContactTime = now
};
// initialize security diagnostics.
m_securityDiagnostics = new SessionSecurityDiagnosticsDataType
{
SessionId = Id,
ClientUserIdOfSession = Identity.DisplayName,
AuthenticationMechanism = Identity.TokenType.ToString(),
Encoding = context.ChannelContext.MessageEncoding.ToString()
};
m_securityDiagnostics.ClientUserIdHistory =
m_securityDiagnostics.ClientUserIdHistory.AddItem(Identity.DisplayName);
EndpointDescription? description = context.ChannelContext.EndpointDescription;
if (description != null)
{
m_securityDiagnostics.TransportProtocol = new Uri(description.EndpointUrl!).Scheme;
m_securityDiagnostics.SecurityMode = EndpointDescription.SecurityMode;
m_securityDiagnostics.SecurityPolicyUri = EndpointDescription.SecurityPolicyUri;
}
if (clientCertificate != null)
{
m_securityDiagnostics.ClientCertificate = clientCertificate.RawData.ToByteString();
}
ServerSystemContext systemContext = m_server.DefaultSystemContext.Copy(context);
// create diagnostics object.
Id = server.DiagnosticsNodeManager.CreateSessionDiagnosticsAsync(
systemContext,
SessionDiagnostics,
OnUpdateDiagnostics,
m_securityDiagnostics,
OnUpdateSecurityDiagnostics).AsTask().GetAwaiter().GetResult();
TraceState("CREATED");
}
/// <summary>
/// Frees any unmanaged resources.
/// </summary>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// An overrideable version of the Dispose.
/// </summary>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
List<ContinuationPoint>? browseCPs;
lock (m_lock)
{
browseCPs = m_browseContinuationPoints;
m_browseContinuationPoints = null;
}
if (browseCPs != null)
{
for (int ii = 0; ii < browseCPs.Count; ii++)
{
browseCPs[ii]?.Dispose();
}
}
List<HistoryContinuationPoint>? historyCPs;
lock (m_lock)
{
historyCPs = m_historyContinuationPoints;
m_historyContinuationPoints = null;
}
if (historyCPs != null)
{
for (int ii = 0; ii < historyCPs.Count; ii++)
{
(historyCPs[ii].Value as IDisposable)?.Dispose();
}
}
m_userTokenNonce?.Dispose();
m_userTokenNonce = null;
IdentityToken = null!;
ClientCertificate?.Dispose();
m_clientIssuerCertificates?.Dispose();
}
}
/// <summary>
/// Gets the identifier assigned to the session when it was created.
/// </summary>
public NodeId Id { get; }
/// <summary>
/// The user identity provided by the client.
/// </summary>
public IUserIdentity Identity { get; private set; }
/// <summary>
/// The application defined mapping for user identity provided by the client.
/// </summary>
public IUserIdentity EffectiveIdentity { get; private set; } = null!;
/// <inheritdoc/>
public bool IsIdentityStale => Volatile.Read(ref m_identityStale) != 0;
/// <inheritdoc/>
public void MarkIdentityStale()
{
Volatile.Write(ref m_identityStale, 1);
}
/// <inheritdoc/>
public void RefreshEffectiveIdentity(IUserIdentity effectiveIdentity)
{
if (effectiveIdentity == null)
{
throw new ArgumentNullException(nameof(effectiveIdentity));
}
lock (m_lock)
{
EffectiveIdentity = effectiveIdentity;
// Clearing the stale flag while holding the session lock
// ensures any subsequent IsIdentityStale read observes a
// consistent (refreshed identity, cleared flag) pair.
Volatile.Write(ref m_identityStale, 0);
}
}
/// <summary>
/// The user identity token provided by the client.
/// </summary>
public IUserIdentityTokenHandler IdentityToken { get; private set; } = null!;
/// <summary>
/// A lock which must be acquired before accessing the diagnostics.
/// </summary>
public object DiagnosticsLock => SessionDiagnostics;
/// <summary>
/// The diagnostics associated with the session.
/// </summary>
public SessionDiagnosticsDataType SessionDiagnostics { get; }
/// <summary>
/// The client Nonce associated with the session.
/// </summary>
public ByteString ClientNonce { get; }
/// <summary>
/// The server application instance certificate used by this session.
/// </summary>
public Certificate ServerCertificate => m_serverCertificate;
/// <summary>
/// The application instance certificate associated with the client.
/// </summary>
public Certificate ClientCertificate { get; }
/// <summary>
/// The locales requested when the session was created.
/// </summary>
public string[] PreferredLocales { get; private set; } = null!;
/// <summary>
/// Whether the session timeout has elapsed since the last communication from the client.
/// </summary>
public bool HasExpired
{
get
{
lock (DiagnosticsLock)
{
return m_timeProvider.GetTimestampMilliseconds() - m_lastContactTickCount >
(long)SessionDiagnostics.ActualSessionTimeout;
}
}
}
/// <summary>
/// The last time the session was contacted by the client.
/// </summary>
public DateTime ClientLastContactTime
{
get
{
lock (DiagnosticsLock)
{
return (DateTime)SessionDiagnostics.ClientLastContactTime;
}
}
}
/// <summary>
/// The monotonic tick count (milliseconds) at the last client contact.
/// Used for timeout calculations that are immune to system time changes.
/// </summary>
public long LastContactTickCount
{
get
{
lock (DiagnosticsLock)
{
return m_lastContactTickCount;
}
}
}
/// <summary>
/// Whether the session has been activated.
/// </summary>
public bool Activated { get; private set; }
/// <summary>
/// Set the ECC security policy URI
/// </summary>
public virtual void SetUserTokenSecurityPolicy(string securityPolicyUri)
{
lock (m_lock)
{
m_userTokenSecurityPolicyUri = securityPolicyUri;
m_userTokenNonce = null;
}
}
/// <summary>
/// Create new ECC ephemeral key
/// </summary>
/// <returns>A new ephemeral key</returns>
public virtual EphemeralKeyType? GetNewEphemeralKey()
{
lock (m_lock)
{
if (m_userTokenSecurityPolicyUri == null)
{
return null;
}
m_userTokenNonce = Nonce.CreateNonce(m_userTokenSecurityPolicyUri);
return new EphemeralKeyType
{
PublicKey = m_userTokenNonce.Data.ToByteString(),
Signature = CryptoUtils.Sign(
new ArraySegment<byte>(m_userTokenNonce.Data!),
m_serverCertificate,
m_userTokenSecurityPolicyUri).ToByteString()
};
}
}
/// <summary>
/// Returns the session's endpoint
/// </summary>
public EndpointDescription EndpointDescription { get; } = null!;
/// <summary>
/// Returns the session's SecureChannelId
/// </summary>
public string SecureChannelId { get; private set; }
/// <summary>
/// allow derived classes access
/// </summary>
protected int MaxBrowseContinuationPoints { get; set; }
/// <summary>
/// Validates the request.
/// </summary>
/// <exception cref="ArgumentNullException"><paramref name="requestHeader"/> is <c>null</c>.</exception>
/// <exception cref="ServiceResultException"></exception>
public virtual void ValidateRequest(RequestHeader requestHeader, SecureChannelContext secureChannelContext, RequestType requestType)
{
if (requestHeader == null)
{
throw new ArgumentNullException(nameof(requestHeader));
}
lock (m_lock)
{
if (secureChannelContext == null || !IsSecureChannelValid(secureChannelContext.SecureChannelId))
{
UpdateDiagnosticCounters(requestType, true, true);
if (requestType == RequestType.CloseSession)
{
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
}
throw new ServiceResultException(StatusCodes.BadSecureChannelIdInvalid);
}
// verify that session has been activated.
if (!Activated && requestType != RequestType.CloseSession)
{
UpdateDiagnosticCounters(requestType, true, true);
throw new ServiceResultException(StatusCodes.BadSessionNotActivated);
}
// request accepted.
UpdateDiagnosticCounters(requestType, false, false);
}
}
/// <summary>
/// Validate the diagnostic info.
/// </summary>
public virtual void ValidateDiagnosticInfo(RequestHeader requestHeader)
{
const uint additionalInfoDiagnosticsMask = (uint)(
DiagnosticsMasks.ServiceAdditionalInfo | DiagnosticsMasks.OperationAdditionalInfo);
if ((requestHeader.ReturnDiagnostics & additionalInfoDiagnosticsMask) != 0)
{
ArrayOf<NodeId> currentRoleIds = EffectiveIdentity?.GrantedRoleIds ?? default;
if (currentRoleIds.Contains(ObjectIds.WellKnownRole_SecurityAdmin) ||
currentRoleIds.Contains(ObjectIds.WellKnownRole_ConfigureAdmin))
{
requestHeader.ReturnDiagnostics
|= (uint)DiagnosticsMasks.UserPermissionAdditionalInfo;
}
}
}
/// <summary>
/// Checks if the secure channel is currently valid.
/// </summary>
public virtual bool IsSecureChannelValid(string secureChannelId)
{
lock (m_lock)
{
return SecureChannelId == secureChannelId;
}
}
/// <summary>
/// Updates the requested locale ids.
/// </summary>
/// <returns>true if the new locale ids are different from the old locale ids.</returns>
/// <exception cref="ArgumentNullException"><paramref name="localeIds"/> is <c>null</c>.</exception>
public bool UpdateLocaleIds(ArrayOf<string> localeIds)
{
lock (m_lock)
{
string[] ids = [.. localeIds];
if (!Utils.IsEqual(ids, PreferredLocales))
{
PreferredLocales = ids;
// update diagnostics.
lock (DiagnosticsLock)
{
SessionDiagnostics.LocaleIds = [.. localeIds];
}
return true;
}
return false;
}
}
/// <summary>
/// Activates the session and binds it to the current secure channel.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
public void ValidateBeforeActivate(
OperationContext context,
SignatureData clientSignature,
ExtensionObject userIdentityToken,
SignatureData userTokenSignature,
out IUserIdentityTokenHandler? identityToken,
out UserTokenPolicy? userTokenPolicy)
{
lock (m_lock)
{
// verify that a secure channel was specified.
if (context.ChannelContext == null)
{
throw new ServiceResultException(StatusCodes.BadSecureChannelIdInvalid);
}
// verify that the same security policy has been used.
EndpointDescription? endpoint = context.ChannelContext.EndpointDescription;
if (endpoint!.SecurityPolicyUri != EndpointDescription.SecurityPolicyUri ||
endpoint.SecurityMode != EndpointDescription.SecurityMode)
{
throw new ServiceResultException(StatusCodes.BadSecurityPolicyRejected);
}
// verify the client signature.
if (ClientCertificate != null)
{
if (EndpointDescription.SecurityPolicyUri != SecurityPolicies.None &&
clientSignature != null &&
clientSignature.Signature.IsEmpty)
{
throw new ServiceResultException(
StatusCodes.BadApplicationSignatureInvalid);
}
SecurityPolicyInfo securityPolicy = SecurityPolicies.GetInfo(EndpointDescription.SecurityPolicyUri!)!;
byte[] dataToSign = securityPolicy!.GetClientSignatureData(
context.ChannelContext.ChannelThumbprint,
m_serverNonce.Data,
m_serverCertificate.RawData,
context.ChannelContext.ServerChannelCertificate,
context.ChannelContext.ClientChannelCertificate,
ClientNonce.ToArray());
if (!SecurityPolicies.VerifySignatureData(
clientSignature!,
EndpointDescription.SecurityPolicyUri!,
ClientCertificate,
dataToSign))
{
// verify for certificate chain in endpoint.
// validate the signature with complete chain if the check with leaf certificate failed.
using CertificateCollection serverCertificateChain =
Utils.ParseCertificateChainBlob(
EndpointDescription.ServerCertificate,
m_server.Telemetry);
if (serverCertificateChain.Count > 1)
{
var serverCertificateChainList = new List<byte>();
for (int i = 0; i < serverCertificateChain.Count; i++)
{
serverCertificateChainList.AddRange(
serverCertificateChain[i].RawData);
}
byte[] serverCertificateChainData = [.. serverCertificateChainList];
dataToSign = securityPolicy.GetClientSignatureData(
context.ChannelContext.ChannelThumbprint,
m_serverNonce.Data,
serverCertificateChainData,
context.ChannelContext.ServerChannelCertificate,
context.ChannelContext.ClientChannelCertificate,
ClientNonce.ToArray());
if (!SecurityPolicies.VerifySignatureData(
clientSignature!,
EndpointDescription.SecurityPolicyUri!,
ClientCertificate,
dataToSign))
{
throw new ServiceResultException(
StatusCodes.BadApplicationSignatureInvalid);
}
}
else
{
throw new ServiceResultException(
StatusCodes.BadApplicationSignatureInvalid);
}
}
}
if (!Activated)
{
// must active the session on the channel that was used to create it.
if (SecureChannelId != context.ChannelContext.SecureChannelId)
{
throw new ServiceResultException(StatusCodes.BadSecureChannelIdInvalid);
}
}
// validate the user identity token.
identityToken = ValidateUserIdentityToken(
context,
userIdentityToken,
userTokenSignature,
out userTokenPolicy);
TraceState("VALIDATED");
}
}
/// <summary>
/// Activates the session and binds it to the current secure channel.
/// </summary>
public bool Activate(
OperationContext context,
IUserIdentityTokenHandler identityToken,
IUserIdentity identity,
IUserIdentity effectiveIdentity,
ArrayOf<string> localeIds,
Nonce serverNonce)
{
lock (m_lock)
{
// update user identity.
bool changed = false;
if (identityToken != null &&
UpdateUserIdentity(identityToken, identity, effectiveIdentity))
{
changed = true;
}
// update local ids.
if (UpdateLocaleIds(localeIds))
{
changed = true;
}
if (!Activated)
{
// toggle the activated flag.
Activated = true;
TraceState("FIRST ACTIVATION");
}
else
{
// bind to the new secure channel. Activate is invoked from the
// session activation pipeline, which always supplies a channel context.
SecureChannelId = context.ChannelContext!.SecureChannelId;
TraceState("RE-ACTIVATION");
}
// update server nonce.
m_serverNonce = serverNonce;
// update the contact time.
lock (DiagnosticsLock)
{
SessionDiagnostics.ClientLastContactTime = m_timeProvider.GetUtcNow().UtcDateTime;
m_lastContactTickCount = m_timeProvider.GetTimestampMilliseconds();
}
// indicate whether the user context has changed.
return changed;
}
}
/// <summary>
/// Closes a session and removes itself from the address space.
/// </summary>
public async ValueTask CloseAsync(CancellationToken cancellationToken = default)
{
TraceState("CLOSED");
await m_server.DiagnosticsNodeManager
.DeleteSessionDiagnosticsAsync(m_server.DefaultSystemContext, Id, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// Saves a continuation point for a session.
/// </summary>
/// <remarks>
/// If the session has too many continuation points the oldest one is dropped.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="continuationPoint"/> is <c>null</c>.</exception>
public void SaveContinuationPoint(ContinuationPoint continuationPoint)
{
if (continuationPoint == null)
{
throw new ArgumentNullException(nameof(continuationPoint));
}
lock (m_lock)
{
m_browseContinuationPoints ??= [];
// remove the first continuation point if too many points.
while (m_browseContinuationPoints.Count > MaxBrowseContinuationPoints)
{
ContinuationPoint cp = m_browseContinuationPoints[0];
m_browseContinuationPoints.RemoveAt(0);
cp?.Dispose();
}
// add to end of list.
m_browseContinuationPoints.Add(continuationPoint);
}
}
/// <summary>
/// Restores a continuation point for a session.
/// </summary>
/// <remarks>
/// The caller is responsible for disposing the continuation point returned.
/// </remarks>
public ContinuationPoint? RestoreContinuationPoint(ByteString continuationPoint)
{
lock (m_lock)
{
if (m_browseContinuationPoints == null)
{
return null;
}
if (continuationPoint.Length != 16)
{
return null;
}
var id = new Guid(continuationPoint.ToArray());
for (int ii = 0; ii < m_browseContinuationPoints.Count; ii++)
{
if (m_browseContinuationPoints[ii].Id == id)
{
ContinuationPoint cp = m_browseContinuationPoints[ii];
m_browseContinuationPoints.RemoveAt(ii);
return cp;
}
}
return null;
}
}
/// <summary>
/// Saves a continuation point used for historical reads.
/// </summary>
/// <param name="id">The identifier for the continuation point.</param>
/// <param name="continuationPoint">The continuation point.</param>
/// <remarks>
/// If the continuationPoint implements IDisposable it will be disposed when
/// the Session is closed or discarded.
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="continuationPoint"/> is <c>null</c>.</exception>
public void SaveHistoryContinuationPoint(Guid id, object continuationPoint)
{
if (continuationPoint == null)
{
throw new ArgumentNullException(nameof(continuationPoint));
}
lock (m_lock)
{
m_historyContinuationPoints ??= [];
// remove existing continuation point if space needed.
while (m_historyContinuationPoints.Count >= m_maxHistoryContinuationPoints)
{
HistoryContinuationPoint oldCP = m_historyContinuationPoints[0];
m_historyContinuationPoints.RemoveAt(0);
(oldCP.Value as IDisposable)?.Dispose();
}
// create the cp.
var cp = new HistoryContinuationPoint
{
Id = id,
Value = continuationPoint,
Timestamp = DateTime.UtcNow
};
m_historyContinuationPoints.Add(cp);
}
}
/// <summary>
/// Restores a previously saves history continuation point.
/// </summary>
/// <param name="continuationPoint">The identifier for the continuation point.</param>
/// <returns>The save continuation point. null if not found.</returns>
public object? RestoreHistoryContinuationPoint(ByteString continuationPoint)
{
lock (m_lock)
{
if (m_historyContinuationPoints == null)
{
return null;
}
if (continuationPoint.Length != 16)
{
return null;
}
var id = new Guid(continuationPoint.ToArray());
for (int ii = 0; ii < m_historyContinuationPoints.Count; ii++)
{
HistoryContinuationPoint cp = m_historyContinuationPoints[ii];
if (cp.Id == id)
{
m_historyContinuationPoints.RemoveAt(ii);
return cp.Value;
}
}
return null;
}
}
/// <summary>
/// Stores a continuation point used for historial reads.
/// </summary>
private class HistoryContinuationPoint
{
public Guid Id;
public object? Value;
public DateTime Timestamp;
}
/// <summary>
/// Dumps the current state of the session queue.
/// </summary>
internal void TraceState(string context)
{
// Legacy event source logging
ServerUtils.EventLog.SessionState(
context,
Id.ToString(),
m_sessionName,
SecureChannelId,
Identity?.DisplayName ?? "(none)");
m_logger.LogInformation(
"Session {Context}, Id={SessionId}, Name={Name}, ChannelId={ChannelId}, User={User}",
context,
Id.ToString(),
m_sessionName,
SecureChannelId,
Identity?.DisplayName ?? "(none)");
}
/// <summary>
/// Returns a copy of the current diagnostics.
/// </summary>
private ServiceResult OnUpdateDiagnostics(
ISystemContext context,
NodeState node,
ref Variant value)
{
lock (DiagnosticsLock)
{
value = Variant.FromStructure(SessionDiagnostics);
}
return ServiceResult.Good;
}
/// <summary>
/// Returns a copy of the current security diagnostics.
/// </summary>
private ServiceResult OnUpdateSecurityDiagnostics(
ISystemContext context,
NodeState node,
ref Variant value)
{
lock (DiagnosticsLock)
{
value = Variant.FromStructure(m_securityDiagnostics);
}
return ServiceResult.Good;
}
/// <summary>
/// Validates the identity token supplied by the client.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
private IUserIdentityTokenHandler ValidateUserIdentityToken(
OperationContext context,
ExtensionObject identityToken,
SignatureData userTokenSignature,
out UserTokenPolicy? policy)
{
policy = null!;
// check for anonymous (same as empty) token.
if (identityToken.IsNull ||
identityToken.TryGetValue(out AnonymousIdentityToken? _))
{
// check if an anonymous login is permitted.
if (!EndpointDescription.UserIdentityTokens.IsEmpty)
{
bool found = false;
for (int ii = 0; ii < EndpointDescription.UserIdentityTokens.Count; ii++)
{
if (EndpointDescription.UserIdentityTokens[ii]
.TokenType == UserTokenType.Anonymous)
{
found = true;
policy = EndpointDescription.UserIdentityTokens[ii];
break;
}
}
if (!found)
{
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenRejected,
"Anonymous user token policy not supported.");
}
}
// create an anonymous token to use for subsequent validation.
return AnonymousIdentityTokenHandler.Create(policy!);
}
IUserIdentityTokenHandler token;
// check for unrecognized token.
if (identityToken.TryGetValue(out UserIdentityToken? decodedToken))
{
// get the token.
token = decodedToken.AsTokenHandler();
}
else
{
//handle the use case when the UserIdentityToken is binary encoded over xml message encoding
if (identityToken.Encoding != ExtensionObjectEncoding.Binary ||
!identityToken.TryGetAsBinary(out ByteString _))
{
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid user identity token provided.");
}
if (BaseVariableState.DecodeExtensionObject(
null!,
typeof(UserIdentityToken),
identityToken,
false)
is not UserIdentityToken newToken)
{
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid user identity token provided.");
}
policy = EndpointDescription.FindUserTokenPolicy(
newToken.PolicyId!,
EndpointDescription.SecurityPolicyUri!) ??
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"User token policy not supported.",
"Opc.Ua.Server.Session.ValidateUserIdentityToken");
UserIdentityToken? userToken;
switch (policy.TokenType)
{
case UserTokenType.Anonymous:
userToken = (AnonymousIdentityToken)BaseVariableState.DecodeExtensionObject(
null!,
typeof(AnonymousIdentityToken),
identityToken,
true)!;
break;
case UserTokenType.UserName:
userToken = (UserNameIdentityToken)BaseVariableState.DecodeExtensionObject(
null!,
typeof(UserNameIdentityToken),
identityToken,
true)!;
break;
case UserTokenType.Certificate:
userToken = (X509IdentityToken)BaseVariableState.DecodeExtensionObject(
null!,
typeof(X509IdentityToken),
identityToken,
true)!;
break;
case UserTokenType.IssuedToken:
userToken = (IssuedIdentityToken)BaseVariableState.DecodeExtensionObject(
null!,
typeof(IssuedIdentityToken),
identityToken,
true)!;
break;
default:
throw ServiceResultException.Create(
StatusCodes.BadUserAccessDenied,
"Invalid user identity token provided.");
}
token = userToken.AsTokenHandler()!;
}
// find the user token policy.
policy = EndpointDescription.FindUserTokenPolicy(
token.Token.PolicyId!,
EndpointDescription.SecurityPolicyUri!) ??
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
"User token policy not supported.");
token.UpdatePolicy(policy);
// determine the security policy uri.
string? securityPolicyUri = policy.SecurityPolicyUri;
if (string.IsNullOrEmpty(securityPolicyUri))
{
securityPolicyUri = EndpointDescription.SecurityPolicyUri;
}
if (ServerBase.RequireEncryption(EndpointDescription))
{
// decrypt the token.
// check for valid certificate.
m_serverCertificate ??= Certificate.FromRawData(
EndpointDescription.ServerCertificate) ??
throw ServiceResultException.ConfigurationError(
"ApplicationCertificate cannot be found.");
try
{
// Sync-completing ValueTask in current implementations;
// safe to block. Future async stores will require
// hoisting decryption out of the lock.
ValueTask decryptTask = token.DecryptAsync(
m_serverCertificate,
m_serverNonce,
securityPolicyUri!,
m_server.MessageContext,
m_userTokenNonce,
ClientCertificate,
m_clientIssuerCertificates);
if (!decryptTask.IsCompletedSuccessfully)
{
decryptTask.AsTask().GetAwaiter().GetResult();
}
}
catch (Exception e) when (e is not ServiceResultException)
{
throw ServiceResultException.Create(
StatusCodes.BadIdentityTokenInvalid,
e,
"Could not decrypt identity token.");
}
// verify the signature.
if (securityPolicyUri != SecurityPolicies.None)
{
SecurityPolicyInfo securityPolicy = SecurityPolicies.GetInfo(securityPolicyUri!)!;
// ValidateUserIdentityToken runs inside session activation, which
// always carries a channel context.
SecureChannelContext channelContext = context.ChannelContext!;
byte[] dataToSign = securityPolicy!.GetUserTokenSignatureData(
channelContext.ChannelThumbprint,
m_serverNonce.Data,
m_serverCertificate.RawData,
channelContext.ServerChannelCertificate,
ClientCertificate?.RawData,
channelContext.ClientChannelCertificate,
ClientNonce.ToArray());
if (!VerifySync(token, dataToSign, userTokenSignature, securityPolicyUri!))
{
// verify for certificate chain in endpoint.
// validate the signature with complete chain if the check with leaf certificate failed.
using CertificateCollection serverCertificateChain =
Utils.ParseCertificateChainBlob(
EndpointDescription.ServerCertificate,
m_server.Telemetry);
if (serverCertificateChain.Count > 1)
{
var serverCertificateChainList = new List<byte>();
for (int i = 0; i < serverCertificateChain.Count; i++)
{
serverCertificateChainList.AddRange(
serverCertificateChain[i].RawData);
}
dataToSign = securityPolicy.GetUserTokenSignatureData(
channelContext.ChannelThumbprint,
m_serverNonce.Data,
[.. serverCertificateChainList],
channelContext.ServerChannelCertificate,
ClientCertificate?.RawData,
channelContext.ClientChannelCertificate,
ClientNonce.ToArray());
if (!VerifySync(token, dataToSign, userTokenSignature, securityPolicyUri!))
{
throw new ServiceResultException(
StatusCodes.BadIdentityTokenRejected,
"Invalid user signature!");
}
}
else
{
throw new ServiceResultException(
StatusCodes.BadIdentityTokenRejected,
"Invalid user signature!");
}
}
}
}
// validate user identity token.
return token;
}
/// <summary>
/// Synchronously invokes <see cref="IUserIdentityTokenHandler.VerifyAsync"/>
/// on the assumption that the underlying implementation completes
/// synchronously (no real I/O). Used inside locked regions of
/// <see cref="ValidateBeforeActivate"/> where awaiting is not
/// possible. Future async-store implementations will require
/// hoisting verification out of the lock.
/// </summary>
private static bool VerifySync(
IUserIdentityTokenHandler token,
byte[] dataToSign,
SignatureData userTokenSignature,
string securityPolicyUri)
{
ValueTask<bool> task = token.VerifyAsync(
dataToSign,
userTokenSignature,
securityPolicyUri);
return task.IsCompletedSuccessfully
? task.Result
: task.AsTask().GetAwaiter().GetResult();
}
/// <summary>
/// Updates the user identity.
/// </summary>
/// <returns>true if the new identity is different from the old identity.</returns>
/// <exception cref="ArgumentNullException"><paramref name="identityToken"/> is <c>null</c>.</exception>
[MemberNotNull(nameof(EffectiveIdentity), nameof(IdentityToken))]
private bool UpdateUserIdentity(
IUserIdentityTokenHandler identityToken,
IUserIdentity identity,
IUserIdentity effectiveIdentity)
{
if (identityToken == null)
{
throw new ArgumentNullException(nameof(identityToken));
}
lock (m_lock)
{
bool changed = EffectiveIdentity == null && effectiveIdentity != null;
if (EffectiveIdentity != null)
{
changed = !EffectiveIdentity.Equals(effectiveIdentity);
}
// always save the new identity since it may have additional information that does not affect equality.
IdentityToken = identityToken;
Identity = identity;
EffectiveIdentity = effectiveIdentity!;
// update diagnostics.
lock (DiagnosticsLock)
{
m_securityDiagnostics.ClientUserIdOfSession = identity.DisplayName;
m_securityDiagnostics.AuthenticationMechanism = identity.TokenType.ToString();
m_securityDiagnostics.ClientUserIdHistory =
m_securityDiagnostics.ClientUserIdHistory.AddItem(identity.DisplayName);
}
return changed;
}
}
/// <summary>
/// Updates the diagnostic counters associated with the request.
/// </summary>
/// <exception cref="ServiceResultException"></exception>
private void UpdateDiagnosticCounters(
RequestType requestType,
bool error,
bool authorizationError)
{
ServiceCounterDataType? counter = null;
lock (DiagnosticsLock)
{
if (!error)
{
SessionDiagnostics.ClientLastContactTime = m_timeProvider.GetUtcNow().UtcDateTime;
m_lastContactTickCount = m_timeProvider.GetTimestampMilliseconds();
}
SessionDiagnostics.TotalRequestCount.TotalCount++;
if (error)
{
SessionDiagnostics.TotalRequestCount.ErrorCount++;
if (authorizationError)
{
SessionDiagnostics.UnauthorizedRequestCount++;
}
}
switch (requestType)
{
case RequestType.Read:
counter = SessionDiagnostics.ReadCount;
break;
case RequestType.HistoryRead:
counter = SessionDiagnostics.HistoryReadCount;
break;
case RequestType.Write:
counter = SessionDiagnostics.WriteCount;
break;
case RequestType.HistoryUpdate:
counter = SessionDiagnostics.HistoryUpdateCount;
break;
case RequestType.Call:
counter = SessionDiagnostics.CallCount;
break;
case RequestType.CreateMonitoredItems:
counter = SessionDiagnostics.CreateMonitoredItemsCount;
break;
case RequestType.ModifyMonitoredItems:
counter = SessionDiagnostics.ModifyMonitoredItemsCount;
break;
case RequestType.SetMonitoringMode:
counter = SessionDiagnostics.SetMonitoringModeCount;
break;
case RequestType.SetTriggering:
counter = SessionDiagnostics.SetTriggeringCount;
break;
case RequestType.DeleteMonitoredItems:
counter = SessionDiagnostics.DeleteMonitoredItemsCount;
break;
case RequestType.CreateSubscription:
counter = SessionDiagnostics.CreateSubscriptionCount;
break;
case RequestType.ModifySubscription:
counter = SessionDiagnostics.ModifySubscriptionCount;
break;
case RequestType.SetPublishingMode:
counter = SessionDiagnostics.SetPublishingModeCount;
break;
case RequestType.Publish:
counter = SessionDiagnostics.PublishCount;
break;
case RequestType.Republish:
counter = SessionDiagnostics.RepublishCount;
break;
case RequestType.TransferSubscriptions:
counter = SessionDiagnostics.TransferSubscriptionsCount;
break;
case RequestType.DeleteSubscriptions:
counter = SessionDiagnostics.DeleteSubscriptionsCount;
break;
case RequestType.AddNodes:
counter = SessionDiagnostics.AddNodesCount;
break;
case RequestType.AddReferences:
counter = SessionDiagnostics.AddReferencesCount;
break;
case RequestType.DeleteNodes:
counter = SessionDiagnostics.DeleteNodesCount;
break;
case RequestType.DeleteReferences:
counter = SessionDiagnostics.DeleteReferencesCount;
break;
case RequestType.Browse:
counter = SessionDiagnostics.BrowseCount;
break;
case RequestType.BrowseNext:
counter = SessionDiagnostics.BrowseNextCount;
break;
case RequestType.TranslateBrowsePathsToNodeIds:
counter = SessionDiagnostics.TranslateBrowsePathsToNodeIdsCount;
break;
case RequestType.QueryFirst:
counter = SessionDiagnostics.QueryFirstCount;
break;
case RequestType.QueryNext:
counter = SessionDiagnostics.QueryNextCount;
break;
case RequestType.RegisterNodes:
counter = SessionDiagnostics.RegisterNodesCount;
break;
case RequestType.UnregisterNodes:
counter = SessionDiagnostics.UnregisterNodesCount;
break;
case RequestType.Unknown:
case RequestType.FindServers:
case RequestType.GetEndpoints:
case RequestType.CreateSession:
case RequestType.ActivateSession:
case RequestType.CloseSession:
case RequestType.Cancel:
break;
default:
throw ServiceResultException.Unexpected(
$"Unexpected RequestType {requestType}");
}
if (counter != null)
{
counter.TotalCount++;
if (error)
{
counter.ErrorCount++;
}
}
}
if (counter != null)
{
m_server.SessionManager.RaiseSessionDiagnosticsChangedEvent(this);
}
}
private readonly Lock m_lock = new();
private readonly ILogger m_logger;
private readonly IServerInternal m_server;
private readonly TimeProvider m_timeProvider;
private readonly string m_sessionName;
private Certificate m_serverCertificate;
private Nonce m_serverNonce;
private string? m_userTokenSecurityPolicyUri;
private Nonce? m_userTokenNonce;
private readonly CertificateCollection? m_clientIssuerCertificates;
private readonly int m_maxHistoryContinuationPoints;
private readonly SessionSecurityDiagnosticsDataType m_securityDiagnostics;
private List<ContinuationPoint>? m_browseContinuationPoints;
private List<HistoryContinuationPoint>? m_historyContinuationPoints;
private long m_lastContactTickCount;
private int m_identityStale;
}
}