Files
Marc Schier 15eb55c46e Cert rotation: force renegotiate (#3562) + auto-detect on client (#3160) (#3849)
# Description

Implements the OPC UA Part 12 §7.10.9 contract on the server (fixes
#3562) AND the client-side counterpart for immediate detection of
own-cert / trust-list / CRL changes (fixes #3160).

## Server side (#3562)

Per Part 12 §7.10.9 the server _shall_ "force existing SecureChannels
affected by the changes to renegotiate" once the `ApplyChanges` method
response has been delivered. Previously the server reloaded the
certificate but did not cut affected channels; clients kept using the
old cert until natural rekey, and the code carried an explicit `TODO`
next to a 1-second `Task.Delay` hack in
`ConfigurationNodeManager.ApplyChanges`.

Key server changes:

* **New optional capability interface**
`Opc.Ua.ITransportListenerCertificateRotation` (single method
`CloseChannelsForCertificate(Certificate)`) — implemented by
`TcpTransportListener` and `HttpsTransportListener`. Custom transport
listeners can opt in without breaking `ITransportListener`.
* **TCP path**: new internal
`TcpListenerChannel.TryCloseForCertificateRotation(oldThumbprint, out
globalChannelId)` matches the channel's negotiated `ServerCertificate`
by thumbprint, sends `BadCertificateInvalid`, and calls
`ChannelFaulted()` to actually close the socket.
`TcpTransportListener.CloseChannelsForCertificate` snapshots
`m_channels` outside `m_lock` to avoid lock inversion, invokes the
per-channel helper, returns cut global channel ids. **The listener
socket stays bound** — clients can reconnect immediately.
* **HTTPS path**: HTTPS owns the cert at the Kestrel/HTTP.sys listener
level, so a `Stop()`+`Start()` cycle is the only practical
force-renegotiate. Moved out of `CertificateUpdate` (which now does only
the soft fan-out) into `CloseChannelsForCertificate` so the staging
event no longer disrupts in-flight requests prematurely.
* **`ConfigurationNodeManager.ApplyChanges`** rewritten:
- Captures the per-group pre-transaction certificate (preserved across
multi-stage transactions per Part 12 §7.10.2) and the staged
`UpdateCertificateData`.
- Schedules a deferred apply: waits for the response to flush, re-syncs
the registry via `CertificateManager.UpdateAsync`, then calls
`CloseChannelsForCertificate` on every listener that implements the
capability.
- **Configurable grace period**: new
`IConfigurationNodeManager.ApplyChangesGracePeriod` (default 250 ms,
negative clamped to zero). Hosts running over higher-latency
administrative links can raise it.
- Exposes
`IConfigurationNodeManager.DrainPendingApplyChangesAsync(CancellationToken)`
for tests/hosts to deterministically await rotation.
  - Removes the explicit 1-second `Task.Delay` TODO hack.
* **`Opc.Ua.Server.ITransportListenerRegistryProvider`** — narrow opt-in
interface on `ServerInternalData` exposing the live transport listener
list. Lets `ConfigurationNodeManager` drive the fan-out without
expanding `IServerInternal` (binary compat for mocked test fixtures).
* **Sessions are NOT closed**. Per spec, the cut is on the SecureChannel
only; existing `Session.IsSecureChannelValid` + `Session.Activate`
rebind handles transfer over the new channel.

## Client side (#3160)

The client SDK previously had no automatic detection of changes to its
own certificate, trust list, or CRL — users had to call
`Session.ReloadInstanceCertificateAsync` manually and trigger a
reconnect. The spec-mandated `ApplicationCertificateUpdated`,
`TrustListUpdated`, and `CrlUpdated` enum values existed but only the
first was being emitted by `CertificateManager`.

Key client/shared changes:

* **`CertificateManager` now emits `TrustListUpdated` and `CrlUpdated`**
from `WriteTrustListAsync` (per mask) and from
`TrustListTransaction.CommitAsync` (after the atomic apply). The new
internal `NotifyTrustListChanged` also invalidates cached
peer/user/HTTPS validation cores so subsequent validations pick up the
fresh trust state.
* **`ManagedSession.AutoReconnectOnCertificateChange`** opt-in property
(default `false` for backwards compat). When `true`, `ManagedSession`
subscribes to `CertificateManager.CertificateChanges` on construction
and, on the three relevant kinds:
- Calls `Session.ReloadInstanceCertificateAsync` on
`ApplicationCertificateUpdated` so the next ActivateSession is signed
with the rotated client cert.
- Triggers a reconnect via the state machine so the new state is
honoured within milliseconds rather than at the next
`SecurityTokenLifetime` rekey.
* **`ManagedSession.ApplicationCertificateChanged`** event surfaces
every observed change regardless of the auto-reconnect policy for
diagnostics or custom rotation logic.
* Subscription is disposed by the existing `Dispose` / `DisposeAsync`
paths.

```csharp
using var managed = await ManagedSession.CreateAsync(
    configuration, endpoint, sessionFactory);
managed.AutoReconnectOnCertificateChange = true;

managed.ApplicationCertificateChanged += (sender, e) =>
{
    Console.WriteLine($"observed {e.Kind} on {e.TrustList}");
};
```

## Tests

| Suite | net10.0 | net48 |
|---|---|---|
| `TransportListenerCertificateRotationTests` (new, 6 tests) | ✅ | ✅ |
| `CertificateManagerTests` (4 new event tests) | ✅ 22/22 | ✅ 4/4 |
| `PushCertManagement` | 50/53 ✅ | 50/53 ✅ |
| `GDS PushTest` (focused subset) | 15/15 ✅ | 16/16 ✅ |
| `Opc.Ua.Server.Tests` (focused subset) | 38/38 ✅ | 38/38 ✅ |
| `GDS PushTest` (full) | 313/338 ✅ | — |

Skips are pre-existing (cert-type mismatches, optional methods) — no
regressions.

New integration test
`PushTest.ApplyChangesForcesChannelRenegotiateAsync` positively asserts
the stale channel fails with a recognized channel-invalidation status
after `ApplyChanges`, and that a fresh reconnect succeeds with the
rotated cert.

## Documentation

- `Docs/CertificateManager.md` — new sections:
- "Server-Side Certificate Rotation via Push (OPC UA Part 12 §7.10.9)" —
`ITransportListenerCertificateRotation` contract + custom listener
example.
- "Client-Side Auto-Detection of Certificate Changes (issue #3160)" —
`AutoReconnectOnCertificateChange` + `ApplicationCertificateChanged`
event + the three covered scenarios.

## Related Issues

- Fixes #3562
- Fixes #3160

## Checklist

- [ ] 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.
- [x] I ran focused tests locally using the **UA.slnx** projects against
.net **framework 4.8** and .net **10**, and all passed. _(Full UA.slnx
test run delegated to CI.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-08 12:19:49 +02:00

156 lines
7.0 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.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Server
{
/// <summary>
/// The Server Configuration Node Manager.
/// </summary>
public interface IConfigurationNodeManager : IAsyncNodeManager
{
/// <summary>
/// Raised when the <c>DefaultRolePermissions</c> or <c>DefaultUserRolePermissions</c>
/// property of any <see cref="NamespaceMetadataState"/> node changes.
/// Subscribers should invalidate any cached role-permission validation results.
/// </summary>
event EventHandler DefaultPermissionsChanged;
/// <summary>
/// Gets or creates the <see cref="NamespaceMetadataState"/> node for the specified NamespaceUri.
/// </summary>
ValueTask<NamespaceMetadataState> CreateNamespaceMetadataStateAsync(string namespaceUri, CancellationToken cancellationToken = default);
/// <summary>
/// Creates the configuration node for the server.
/// </summary>
void CreateServerConfiguration(ServerSystemContext systemContext, ApplicationConfiguration configuration);
/// <summary>
/// Gets and returns the <see cref="NamespaceMetadataState"/> node associated with the specified NamespaceUri
/// </summary>
ValueTask<NamespaceMetadataState?> GetNamespaceMetadataStateAsync(string namespaceUri, CancellationToken cancellationToken = default);
/// <summary>
/// Gets and returns the <see cref="NamespaceMetadataState"/> node associated with the specified namespace index
/// </summary>
ValueTask<NamespaceMetadataState?> GetNamespaceMetadataStateAsync(ushort namespaceIndex, CancellationToken cancellationToken = default);
/// <summary>
/// Binds the standard KeyCredentialConfiguration folder to the supplied push subject.
/// </summary>
ValueTask BindKeyCredentialPushAsync(
KeyCredentialPushSubject subject,
CancellationToken cancellationToken = default);
/// <summary>
/// Determine if the impersonated user has admin access.
/// </summary>
/// <exception cref="ServiceResultException"/>
/// <seealso cref="StatusCodes.BadUserAccessDenied"/>
void HasApplicationSecureAdminAccess(ISystemContext context);
/// <summary>
/// Determine if the impersonated user has admin access.
/// </summary>
/// <exception cref="ServiceResultException"/>
/// <seealso cref="StatusCodes.BadUserAccessDenied"/>
void HasApplicationSecureAdminAccess(ISystemContext context, CertificateStoreIdentifier trustedStore);
/// <summary>
/// Starts periodic evaluation of <c>CertificateExpired</c> and
/// <c>TrustListOutOfDate</c> alarms per OPC 10000-12 §7.8.3.
/// Call this after the server is fully started so that
/// <c>SetActiveState</c> can safely emit event notifications.
/// </summary>
/// <param name="interval">
/// Evaluation interval. A typical value is 60 seconds.
/// </param>
void StartAlarmMonitoring(TimeSpan interval);
/// <summary>
/// Stops the periodic alarm evaluation.
/// </summary>
void StopAlarmMonitoring();
/// <summary>
/// The grace period that elapses between the
/// <c>ApplyChanges</c> method response being returned and the
/// per-channel force-renegotiate fan-out specified by OPC UA
/// Part 12 §7.10.9. Configurable because the right value depends
/// on the transport latency between the server and its
/// administrative client.
/// </summary>
/// <remarks>
/// <para>
/// Defaults to 250&#160;ms which is adequate for in-process and
/// LAN deployments. Hosts that run the server behind a higher-
/// latency link (e.g. WAN-fronted Push Configuration) should
/// raise this so the <c>ApplyChanges</c> response is flushed
/// before its SecureChannel is torn down.
/// </para>
/// <para>
/// Setting a non-positive value treats the rotation as
/// "immediate" and may cause the calling client to receive a
/// transport error instead of the method response. Tests can
/// instead await
/// <see cref="DrainPendingApplyChangesAsync(CancellationToken)"/>
/// for deterministic completion without relying on the grace
/// period.
/// </para>
/// <para>
/// TODO: replace the grace period with a transport-level
/// "response flushed" hook so the channel cut can run exactly
/// when the response has left the wire — tracked as a follow-up
/// feature on top of this PR.
/// </para>
/// </remarks>
TimeSpan ApplyChangesGracePeriod { get; set; }
/// <summary>
/// Awaits completion of any pending deferred work scheduled by a
/// recent <c>ApplyChanges</c> call. The deferred work includes
/// reloading certificates from disk via
/// <see cref="CertificateManager.UpdateAsync"/> and forcing
/// affected SecureChannels to renegotiate per OPC UA Part 12
/// §7.10.9.
/// </summary>
/// <remarks>
/// Returns immediately when no deferred work is in flight. Used
/// by tests and tightly-coupled hosts to deterministically wait
/// for cert rotation to take effect after a push update.
/// </remarks>
/// <param name="cancellationToken">A token to cancel the wait.</param>
Task DrainPendingApplyChangesAsync(CancellationToken cancellationToken = default);
}
}