Files
UA-.NETStandard/docs/CertificateManager.md
romanett 21e04bdba5 Make Bad_CertificateChainIncomplete unsuppressible; add UpdateCertificate renewal regression tests (#4683)
# Description

Follow-up to the master378 fix in #4678 (CTT: application certificate
renewal must use the configured trust stores). Master already ignores
the supplied `issuerCertificates` for the default application group (OPC
10000-12 §7.10.5), but it had no regression tests for CA-signed
renewals. While adding them I found a real gap:

**The validator treated `Bad_CertificateChainIncomplete` as
suppressible.** OPC 10000-4 Table 100 (Build Certificate Chain) says:
"An error during the chain creation may not be suppressed." §6.1.3 adds:
"Processing fails with Bad_SecurityChecksFailed if an element in the
chain cannot be found." UpdateCertificate for the application group
accepts every *suppressible* error (as §7.10.5 requires). As a result it
accepted a renewal signed by a CA that is **not** in the TrustList and
was only passed in the ignored issuer list, which is exactly what
§7.10.5 rules out.

## Changes
- `CertificateValidationCore`: remove `BadCertificateChainIncomplete`
from the suppressible status codes. An accept callback (`AcceptError`)
is no longer asked about it.
- `TrustList`: remove the local workaround that filtered out this error;
plain "accept suppressible errors" is now enough.
- Docs (`Certificates.md`, `CertificateManager.md`): drop it from the
suppressible list, and fix a sample that suppressed it.

## Tests
- `ConfigurationNodeManagerPushTests`:
- A CA-signed renewal with the CA installed succeeds with a missing,
empty, unrelated or malformed issuer list, and the trust stores stay
unchanged.
- A renewal whose CA is only supplied as an issuer is rejected with
`BadCertificateChainIncomplete`; stores and the current certificate are
untouched.
- End to end on an isolated server: a CA-signed renewal with no issuer
list is committed through ApplyChanges.
- `ConfigurationNodeManagerPushValidationTests`: the group TrustList
validator accepts a CA from the trusted or issuer store and rejects an
uninstalled CA.
- `CertificateValidationCoreTests` / `CertificateValidatorTest`:
- An incomplete chain is unsuppressible and never reaches the callback.
- A leaf trusted directly **stays valid when the peer supplies its CA**
(§6.1.3: chain certificates "may be provided with the application
Certificate").
- One existing test attached its approver only after validating, so it
never exercised suppression; it now validates with the approver
attached.
- Tests that only passed because of the bug
(`PushCertificateRequestRegressionTests`, the regenerated-key helper and
the rollback test) now install the signing CA in the TrustList first.

The two new rejection tests fail on master and pass with this change.

## Reviewer notes / behavior change
This was suppressible in 1.5.x as well. Application code whose accept
handler approved a CA-signed peer certificate without its CA (CA neither
installed nor sent by the peer) will now get a rejection. Without a
handler such certificates were already rejected, and the stack's own
handlers (`ApplicationInstance`, push validation) never accepted this
error. Worth a release note.

Local runs (net10.0), all passing:

| Suite | Passed | Failed |
|---|---|---|
| Server.Tests | 8310 | 0 |
| Core.Tests (security/transport filter) | 2039 | 0 |
| Security.Certificates.Tests | 358 | 0 |
| Core.Security.Tests | 558 | 0 |
| Client.Tests | 2761 | 0 |
| Gds.Tests (PushTest and TrustList tests) | 345 | 0 |

The net48 builds of Server.Tests and Core.Tests also succeed.

## Related Issues

- Related to #4678 (master378 counterpart)

## 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.
- [ ] 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:14:17 +02:00

1173 lines
72 KiB
Markdown

# CertificateManager
`CertificateManager` manages application certificates, trust lists, validation,
and lifecycle notifications. Use [Certificates](Certificates.md) for trust and
store concepts, this guide for current APIs, and the
[certificate migration guide](migrate/2.0.x/certificates.md) for 1.5.x API replacements.
> **Note:** `CertificateIdentifier` is **metadata-only** (`StoreType` / `StorePath` / `SubjectName` / `Thumbprint` / `CertificateType` / `RawData` / `ValidationOptions`). It caches no `Certificate` and is not disposable. The `CertificateManager` (via `ICertificateRegistry`) is the single source of truth for materialized application certificates; `CertificateIdentifierResolver` is the stateless helper that materializes a `Certificate` from an identifier on demand. See *[Materializing a `Certificate` from a `CertificateIdentifier`](#materializing-a-certificate-from-a-certificateidentifier)* below.
## Contents
- [Architecture](#architecture)
- [Quick Start](#quick-start)
- [Creating a CertificateManager](#creating-a-certificatemanager)
- [Instance-scoped store resolution](#instance-scoped-store-resolution)
- [Validating Certificates](#validating-certificates)
- [Subscribing to Certificate Changes](#subscribing-to-certificate-changes)
- [Updating Application Certificates](#updating-application-certificates)
- [Certificates for SecurityMode None](#certificates-for-securitymode-none)
- [Server-Side Certificate Rotation via Push (OPC UA Part 12 §7.10.9)](#server-side-certificate-rotation-via-push-opc-ua-part-12-7109)
- [TrustList-Change Effects on Channels, Sessions and Subscriptions (OPC UA Part 12 §7.10.9)](#trustlist-change-effects-on-channels-sessions-and-subscriptions-opc-ua-part-12-7109)
- [Origin-Independent Enforcement of Trust-Material Changes](#origin-independent-enforcement-of-trust-material-changes)
- [PushManagement Transactions (OPC UA Part 12 §7.10.2-§7.10.11)](#pushmanagement-transactions-opc-ua-part-12-7102-71011)
- [Certificate-Expiration and TrustList-Staleness Alarms (OPC UA Part 12 §7.8.3)](#certificate-expiration-and-trustlist-staleness-alarms-opc-ua-part-12-783)
- [Optional ServerConfiguration Surface (OPC UA Part 12 §7.10.3, §7.10.13, §7.10.20)](#optional-serverconfiguration-surface-opc-ua-part-12-7103-71013-71020)
- [TrustList Size Limits (OPC UA Part 12 §8.4.5)](#trustlist-size-limits-opc-ua-part-12-845)
- [Client-Side Auto-Detection of Certificate Changes](#client-side-auto-detection-of-certificate-changes)
- [Working with Trust-Lists](#working-with-trust-lists)
- [Interfaces Reference](#interfaces-reference)
- [ICertificateRegistry](#icertificateregistry)
- [ICertificateTrustListManager](#icertificatetrustlistmanager)
- [ICertificateValidatorEx](#icertificatevalidatorex)
- [ICertificateLifecycle](#icertificatelifecycle)
- [ITrustListFileAccess](#itrustlistfileaccess)
- [ICertificateFactory](#icertificatefactory)
- [ICertificateIssuer](#icertificateissuer)
- [Pluggable Store Backends](#pluggable-store-backends)
- [Certificate Wrapper and Reference Counting](#certificate-wrapper-and-reference-counting)
- [Backward Compatibility](#backward-compatibility)
- [OPC UA Specification Alignment](#opc-ua-specification-alignment)
- [Materializing a `Certificate` from a `CertificateIdentifier`](#materializing-a-certificate-from-a-certificateidentifier)
- [Resolving an identifier](#resolving-an-identifier)
- [When to register an in-memory certificate with the manager](#when-to-register-an-in-memory-certificate-with-the-manager)
## Architecture
The `CertificateManager` is composed of focused interfaces. Consumers depend only on the slice they need:
```
ICertificateManager (composite)
├── ICertificateRegistry — "What certificates do I have?"
├── ICertificateTrustListManager — "Which stores exist?"
├── ICertificateValidatorEx — "Is this certificate trusted?"
├── ICertificateLifecycle — "What changed?"
└── ITrustListFileAccess — "Read/write trust-list blobs"
Standalone (no CertificateManager dependency):
├── ICertificateFactory — "Create from raw material"
└── ICertificateIssuer — "Sign as a CA"
```
## Quick Start
### Creating a CertificateManager
From an existing `SecurityConfiguration` (most common):
```csharp
using Opc.Ua;
using Opc.Ua.Security.Certificates;
// Create from SecurityConfiguration (auto-registers Peers, Users, Https, Rejected trust-lists)
var manager = CertificateManagerFactory.Create(
securityConfiguration,
telemetry,
options =>
{
options.MaxRejectedCertificates = 10;
options.ExpiryWarningThreshold = TimeSpan.FromDays(30);
// Register custom trust-lists
options.AddTrustList("MqttBrokers",
trustedStorePath: "%LocalApplicationData%/OPC/pki/mqtt/trusted",
issuerStorePath: "%LocalApplicationData%/OPC/pki/mqtt/issuers");
});
// Load application certificates
await manager.LoadApplicationCertificatesAsync(securityConfiguration);
```
The `CertificateManager` is also automatically initialized by `ServerBase` and `ApplicationInstance` during startup.
### Instance-scoped store resolution
`CertificateManager` also implements the optional `ICertificateStoreResolver` capability.
This interface is separate from `ICertificateManager`, so existing custom manager implementations
do not acquire a new required member. A resolver opens stores through the providers registered on
that manager; it does not register them globally or substitute another store after a provider fails.
The caller owns and disposes the returned store. Trust-store access defaults to `noPrivateKeys: true`;
application identity operations explicitly request private-key access.
For identifier-based private-key loading, pass the manager's resolver capability:
```csharp
using Certificate? certificate = await CertificateIdentifierResolver.LoadPrivateKeyWithStoreResolverAsync(
identifier,
configuration.CertificateManager as ICertificateStoreResolver,
configuration.SecurityConfiguration.CertificatePasswordProvider,
configuration.ApplicationUri,
telemetry,
cancellationToken).ConfigureAwait(false);
```
A null resolver preserves the existing built-in store lookup. An injected resolver's exceptions
propagate without falling back to Directory. The lookup retains the password, cancellation token,
and thumbprint/subject/application-URI rotation fallbacks of `LoadPrivateKeyAsync`.
Configuration validation, manager startup loading, the certificate-provider cache cold path and
`ApplicationInstance` provisioning/reload/deletion use this capability when available. The existing-key
GDS signing-request path and server-hosted `TrustList` access use it as well. Direct `TrustList` hosts
can use the overload accepting `ICertificateStoreResolver`; existing constructors retain their behavior.
Provider routing does not change trust acceptance, GDS access controls or transaction semantics.
### Validating Certificates
```csharp
// Validate against the Peers trust-list (default)
CertificateValidationResult result = await manager.ValidateAsync(peerCertificate);
if (!result.IsValid)
{
Console.WriteLine($"Validation failed: {result.StatusCode}");
}
// Validate a user X.509 identity token against the Users trust-list
CertificateValidationResult userResult = await manager.ValidateAsync(
userCertificate,
TrustListIdentifier.Users);
// Validate against a custom trust-list
CertificateValidationResult mqttResult = await manager.ValidateAsync(
brokerCertChain,
new TrustListIdentifier("MqttBrokers"));
// Validate with per-call options, including a per-error accept callback
// (the new-design replacement for the legacy CertificateValidation event).
var options = new CertificateValidationOptions
{
AutoAcceptUntrustedCertificates = true,
AcceptError = (certificate, error) =>
// Suppress host-name mismatches only for self-test scenarios. An incomplete
// chain (BadCertificateChainIncomplete) is never suppressible.
error.StatusCode == StatusCodes.BadCertificateHostNameInvalid
};
CertificateValidationResult devResult = await manager.ValidateAsync(
serverCertificate,
TrustListIdentifier.Peers,
options);
```
`MapFromSecurityConfiguration` includes both store-backed certificates and
explicit `TrustedCertificates` entries for the Peers, Users and Https lists.
Explicit issuers complete chains without themselves granting trust. When a
store is also configured, its revocation checks still apply to explicitly
listed issuers. The manager snapshots the configuration; call `UpdateAsync`
to replace those snapshots and invalidate cached validation results.
An explicit-only list can validate certificates without a `StorePath`;
store-management operations still require a configured backing store.
Set `CertificateValidationOptions.RecordRejectedCertificates = false` for
re-evaluating an existing connection without adding failures to the rejected
store. This changes only the recording side effect, not the trust decision.
New-connection validation records rejected certificates by default.
An admitted validation preserves its result, including URI and hostname errors,
if the manager shuts down before rejected-store recording can begin. Recording
is skipped once shutdown closes writer admission; explicitly requesting
`RejectCertificateAsync` after disposal still fails.
Concatenated certificate blobs are framed by their outer DER sequences, with at
most 16 certificates. Framing does not interpret the signature algorithm;
native import and certificate validation determine whether each certificate is
supported and acceptable, including PSS and EdDSA signatures.
**Inline issuer revocation policy:** when an inline issuer's list has a backing
store that checks CRLs, `RejectUnknownRevocationStatus = true` rejects
`BadCertificateRevocationUnknown` (or `BadCertificateIssuerRevocationUnknown`)
if that store has no current CRL for the issuer. An inline entry does not
implicitly suppress this check. This intentionally tightens compatibility with
validators that accepted inline issuers without checking the configured store:
provide the issuer's current CRL when using strict revocation policy.
An explicit-only list without a backing store retains its no-store behavior.
Disabling unknown-status rejection, or explicitly suppressing unknown status,
never suppresses an actual `BadCertificateRevoked` or `BadCertificateIssuerRevoked`
result. If the issuer is present both in the store and inline, the store's
certificate and validation options take precedence.
This follows the distinction between a suppressible missing revocation list
and non-suppressible revocation in
[OPC 10000-4, 6.1.3, Table 100](https://reference.opcfoundation.org/specs/OPC-10000-4/v1.05.07/6.1.3).
> **Concurrency & caching:** `ValidateAsync` is designed for highly concurrent
> use — a single shared `CertificateManager` validates many certificates in
> parallel without serializing on an internal lock. Each trust-list is backed by
> an immutable, lock-free state snapshot, and the trusted/issuer stores and their
> CRLs are cached and reused across validations (re-read only when the backing
> store changes, e.g. via the trust-list APIs or an out-of-band directory change).
> Servers that validate a client certificate per incoming secure channel therefore
> scale across cores instead of bottlenecking on certificate validation.
Validation operations hold explicit borrows on their selected validation
core. Trust-list invalidation retires the old core without disposing stores
still used by active calls; new calls acquire the new configuration.
Registry enumeration returns stable snapshots, and terminal manager
disposal prevents late certificate publication.
### Subscribing to Certificate Changes
```csharp
// Subscribe to all changes
manager.CertificateChanges.Subscribe(new MyObserver());
// Or filter by trust-list using System.Reactive (optional dependency)
// manager.CertificateChanges
// .Where(e => e.TrustList == TrustListIdentifier.Peers)
// .Subscribe(e => HandlePeerChange(e));
private class MyObserver : IObserver<CertificateChangeEvent>
{
public void OnNext(CertificateChangeEvent e)
{
switch (e.Kind)
{
case CertificateChangeKind.ApplicationCertificateUpdated:
Console.WriteLine($"Certificate updated: {e.CertificateType}");
break;
case CertificateChangeKind.CertificateExpiring:
Console.WriteLine($"Certificate expiring: {e.OldCertificate?.Thumbprint}");
break;
}
}
public void OnError(Exception error) { }
public void OnCompleted() { }
}
```
### Updating Application Certificates
```csharp
// Replace an application certificate (notifies all subscribers synchronously)
await manager.UpdateApplicationCertificateAsync(
ObjectTypeIds.RsaSha256ApplicationCertificateType,
newCertificate,
issuerChain);
```
Certificate-change notifications borrow their certificate and issuer-chain
handles for the synchronous notification. An observer that queues work must
take independent references before returning and release them after processing.
The built-in client rotation pump does this for pending, replaced and processed
notifications.
### Certificates for SecurityMode None
On `SecurityMode.None` endpoints, encrypted username or issued-token policies
use an RSA application certificate compatible with every advertised encrypted
token policy. The selected certificate is also used for `CreateSession`, restored-session certificate
resolution and certificate-deletion protection. An ECC-first application
certificate list does not change this selection.
Generic RSA-family certificate types, including `ApplicationCertificateType`,
are accepted only when the certificate has an actual RSA public key within every
applicable token policy's key-size bounds.
An unspecified encrypted token policy defaults to `Basic256Sha256`. If no
compatible RSA certificate exists, the server omits that policy and logs a
configuration error; it never substitutes ECC or RSA_DH on a None endpoint.
Anonymous, X.509 and explicitly unencrypted token policies retain their existing
semantics. This follows [OPC 10000-4, 7.41](https://reference.opcfoundation.org/specs/OPC-10000-4/7.41.md).
### Server-Side Certificate Rotation via Push (OPC UA Part 12 §7.10.9)
When a client rotates a server's application certificate through the standard `ServerConfiguration.UpdateCertificate` + `ServerConfiguration.ApplyChanges` push flow, the server must — once the `ApplyChanges` response has been delivered — force the SecureChannels that were negotiated against the old certificate to renegotiate. The session (and any subscriptions) stay alive so the client's reconnect logic can transfer them onto a fresh channel.
The stack implements this contract in two pieces:
1. **`ConfigurationNodeManager.ApplyChanges`** captures the old certificate per group at staging time, then schedules a deferred apply that (a) waits ~250 ms for the method response to flush, (b) re-syncs the registry from disk via `CertificateManager.UpdateAsync`, and (c) calls the new channel-cut hook on every transport listener.
2. **`ITransportListenerCertificateRotation`** is an optional capability interface on `ITransportListener`. `TcpTransportListener` implements it by thumbprint-matching the per-channel `ServerCertificate` and force-closing only affected channels (listener socket stays bound). `HttpsTransportListener` implements it by cycling its Kestrel host (`Stop()` + `Start()`).
Tests and hosts that need deterministic timing can await the deferred work via `IConfigurationNodeManager.DrainPendingApplyChangesAsync(CancellationToken)`.
The commit installs the key-bearing certificate in the live registry before
that deferred reload. Signing and token decryption can use the new key during
the response-flush grace period, including when the commit fills an empty slot.
Custom transport listeners opt into the renegotiate hook by implementing the capability interface:
```csharp
public sealed class MyTransportListener : ITransportListener, ITransportListenerCertificateRotation
{
// ...
public IReadOnlyList<string> CloseChannelsForCertificate(Certificate oldCertificate)
{
// Close every channel whose negotiated ServerCertificate.Thumbprint
// matches oldCertificate.Thumbprint. Keep the listener socket bound.
// Return the global channel ids that were closed (for diagnostics).
return [];
}
}
```
Server-base subclasses that want to observe rotation in custom ways can still subscribe to `ICertificateManager.CertificateChanges` and react to `ApplicationCertificateUpdated` events — but should not drive channel teardown from that hook (the event fires twice during a push update: once when the new cert is staged, once when `ApplyChanges` reloads, and only `ApplyChanges` has the real old-cert reference).
### TrustList-Change Effects on Channels, Sessions and Subscriptions (OPC UA Part 12 §7.10.9)
`ApplyChanges` also has to react to *TrustList* changes committed in the transaction, not just server-certificate rotation. Once the response has been delivered, `ConfigurationNodeManager` maps every committed TrustList to the certificate group it belongs to and applies the corresponding effect:
- **Application group TrustList (`opc.tcp`)** validates the *peer* (client application) certificate presented in `OpenSecureChannel` against the `Peers` store. If a committed change (typically a removal of a trusted certificate or issuer) means a currently-connected peer would no longer validate, that peer's `opc.tcp` SecureChannel is forced to renegotiate; peers whose certificate is still trusted — and peers on a group whose TrustList did not change — keep their channels, Sessions and Subscriptions.
- **HTTPS group TrustList** validates certificates used by the request-scoped UA-HTTPS transport (`Https` store). UA-HTTPS has no long-lived, per-peer SecureChannel keyed by the client application certificate. The shared validator checks any client TLS certificate (mutual TLS) against the directory-backed trust stores at the next TLS handshake. Therefore, an HTTPS-group change does **not** trigger channel renegotiation or a connection teardown. Server-certificate rotation on HTTPS is separate; `ITransportListenerCertificateRotation` handles it with a Kestrel Stop/Start cycle.
- **User-token group TrustList** validates X.509 user identity tokens. Every active Session that authenticated with a certificate user identity is re-validated against the updated user TrustList; a Session whose user certificate is no longer trusted is closed together with its Subscriptions (§7.10.9). Anonymous / username / issued-token Sessions are never disturbed.
The effect fan-out runs after the same grace/flush boundary as certificate rotation and is awaitable through `IConfigurationNodeManager.DrainPendingApplyChangesAsync(CancellationToken)`.
Both TCP listeners retain the complete peer chain validated at
`OpenSecureChannel`, including intermediates supplied only by the client.
Their `ITransportListenerPeerCertificateChainRotation` capability passes an
owned chain snapshot to `CloseChannelsForUntrustedPeerChainsAsync`. The
default effect handler prefers this capability and validates the chain
without writing to the rejected store. This prevents an unrelated trust
addition from misclassifying a peer whose intermediate is absent from the
server's issuer store. Removing the peer's trust anchor still closes its
channel.
Custom transports should implement the chain-aware capability when peers can
supply intermediates. The leaf-only capability below remains supported as a
compatibility fallback. A chain snapshot must remain alive until its predicate
finishes, even if the channel closes concurrently.
Two seams make this behaviour injectable and testable:
1. **`ITransportListenerPeerCertificateRotation`** — an optional
`ITransportListener` capability, alongside
`ITransportListenerCertificateRotation`. Implementing listeners expose
their peer-certificate TrustList scope through
`PeerCertificateTrustListScope`. The server routes a committed change to
a listener only when the change targets that scope. This prevents an
`opc.tcp` listener from being revalidated or closed against the HTTPS
store, and vice versa.
Both `opc.tcp` listeners implement this capability with the `Peers` scope:
the raw-socket `TcpTransportListener` and the Kestrel-hosted
`KestrelTcpTransportListener`. Each listener revalidates tracked client
certificates with a caller-supplied asynchronous predicate. It force-closes
only channels whose certificates fail validation, while keeping the
listener socket bound. The UA-HTTPS listener does **not** implement this
capability (see the HTTPS bullet above). Custom transports can opt in:
```csharp
public sealed class MyTransportListener : ITransportListener, ITransportListenerPeerCertificateRotation
{
// The single scope this listener's peer certificates are validated
// against; only a change to this scope reaches CloseChannelsForUntrustedPeersAsync.
public TrustListIdentifier PeerCertificateTrustListScope => TrustListIdentifier.Peers;
public async ValueTask<IReadOnlyList<string>> CloseChannelsForUntrustedPeersAsync(
Func<Certificate, CancellationToken, ValueTask<bool>> isPeerTrustedAsync,
CancellationToken ct = default)
{
// For every channel with a client certificate, await isPeerTrustedAsync(cert).
// Close the channel when it returns false; keep the listener socket bound.
// Return the global channel ids that were closed (for diagnostics).
return [];
}
}
```
2. **`IPushConfigurationTrustListEffectHandler`** — an injectable provider
that performs the two effects above. `ConfigurationNodeManager` creates a
`PushConfigurationTrustListEffectContext` containing the committed effects,
live transport listeners, session manager, certificate validator, and
session-close delegate. It dispatches the context to the handler after the
grace boundary.
The stack creates a default `PushConfigurationTrustListEffectHandler`.
Hosts can register a custom handler through DI
(`services.TryAddSingleton<IPushConfigurationTrustListEffectHandler, ...>()`)
or pass one directly to `MainNodeManagerFactory` or
`ConfigurationNodeManager`.
The default handler's SecureChannel renegotiation summary logs only the aggregate number of affected channels, not their identifiers or certificate contents.
### Origin-Independent Enforcement of Trust-Material Changes
The effect fan-out above is not limited to changes committed through `ApplyChanges`. Two additional mechanisms guarantee that a trust-material change — most importantly a CRL that revokes a connected client's certificate — takes effect immediately instead of at the client's next security-token renewal (default lifetime: one hour):
- **Push commits invalidate the validator.** After the `ApplyChanges` response boundary, and before the §7.10.9 sweep re-validates any channel, `ConfigurationNodeManager` calls `ICertificateTrustListManager.NotifyTrustListChanged` for every committed scope. The certificate manager drops its cached validation cores — including the `UseValidatedCertificates` fast path, which additionally never skips the revocation check any more — so the sweep is guaranteed to observe the committed stores rather than a cached snapshot.
- **Out-of-band changes trigger the same sweep.** The server subscribes to the certificate manager's `CertificateChanges` stream: any `TrustListUpdated` or `CrlUpdated` event — raised by `WriteTrustListAsync`, an `ITrustListTransaction` commit, or an HA shared-store update — is coalesced (a burst of events folds into one pass per scope union) and dispatched through the same `IPushConfigurationTrustListEffectHandler`. Notifications the manager raises about its own `ApplyChanges` commits are skipped, because the deferred apply already runs the precise committed-scope sweep. Two origins need explicit host action: a **legacy non-transactional `TrustList`** raises no event unless the host opts in by calling `TrustList.SetTrustListChangeNotifier(manager, scope)` after constructing it (nothing in the stack wires this automatically), and a bare file copied into a store directory raises no event at all and is only picked up by the next validation (bounded by the directory store's 30-second cache age for new channels).
Session semantics are unchanged: an application-group change (certificate or CRL) cuts only the affected SecureChannels — the Session survives for the client's reconnect logic, and dies with its timeout when the reconnect is rejected — while a user-token-group change closes the invalidated Sessions together with their Subscriptions.
### PushManagement Transactions (OPC UA Part 12 §7.10.2-§7.10.11)
`ConfigurationNodeManager` implements the full PushManagement transaction model: every `UpdateCertificate`, `CreateSelfSignedCertificate`, `DeleteCertificate` and TrustList (`AddCertificate` / `RemoveCertificate` / `Open`+`CloseAndUpdate`) call made within a Session is **staged**, not applied. Nothing takes effect until `ApplyChanges` is called; `CancelChanges` discards the staged work instead. This matches §7.10.2's *Transaction Lifecycle*: a transaction is created automatically on the first staging call, is owned exclusively by the Session that created it (every other Session's staging calls fail with `Bad_TransactionPending`), and is torn down by `ApplyChanges`, `CancelChanges`, or the owning Session closing.
Because the stack always implements the transaction model, every staging Method always reports `ApplyChangesRequired = TRUE` (§7.10.2) — there is no "immediate apply" mode when going through `ConfigurationNodeManager`. The following standard nodes are exposed under `ServerConfiguration` to support the model:
| Node | Kind | Purpose |
| --- | --- | --- |
| `ServerConfiguration.SupportsTransactions` | Variable | Always `true`; advertises transaction-model support to Clients (§7.10.4). |
| `ServerConfiguration.DeleteCertificate` | Method | Deletes the Certificate occupying a CertificateGroup/CertificateType slot (§7.10.7). Staged like `UpdateCertificate`; the endpoint-reference safety check runs when `ApplyChanges` is called (see *Method security and validation* below). |
| `ServerConfiguration.CancelChanges` | Method | Discards the calling Session's staged changes (§7.10.11). Returns `Bad_NothingToDo` if the Session has no active transaction. |
| `ServerConfiguration.TransactionDiagnostics` | Object | Exposes the outcome of the last `ApplyChanges` (start/end time, per-operation results) for troubleshooting (§7.10.3). Its Variables follow the §7.10.17 DataValue-status rules: every Variable reads `Bad_OutOfService` before any transaction has started, `Result` reads `Bad_InvalidState` while a transaction is active (with `EndTime` at `DateTime.MinValue`), and once the transaction completes `Result` is `Good` and carries the `ApplyChanges` outcome (or `Bad_RequestCancelledByClient` after `CancelChanges`). |
**Client call flow** — a full Update-Certificate-then-apply round trip, now available through `IServerPushConfigurationClient`:
```csharp
await using var pushClient = new ServerPushConfigurationClient(configuration, telemetry);
await pushClient.ConnectAsync(endpointUrl);
// Stage the new certificate; the server always reports true here.
bool applyChangesRequired = await pushClient.UpdateCertificateAsync(
certificateGroupId: default, // DefaultApplicationGroup
certificateTypeId,
newCertificate.RawData.ToByteString(),
privateKeyFormat: null, // reuse the existing/pending private key
privateKey: default,
issuerCertificates);
// Nothing above is visible to the server yet. Commit the transaction:
await pushClient.ApplyChangesAsync();
// Or, to abandon everything staged so far instead:
// await pushClient.CancelChangesAsync();
// Removing a certificate slot entirely (§7.10.7) participates in the
// same transaction and also requires ApplyChanges:
await pushClient.DeleteCertificateAsync(certificateGroupId: default, otherCertificateTypeId);
await pushClient.ApplyChangesAsync();
```
TrustList changes made with `AddCertificateAsync` / `RemoveCertificateAsync` / `UpdateTrustListAsync` share the *same* per-Session transaction as the CertificateGroup Methods above — a single `ApplyChanges` commits (or a single `CancelChanges` discards) both kinds of staged work together, and a `TrustList.Open` for writing is itself blocked with `Bad_TransactionPending` while another Session's transaction is active.
**DI and replacement points.** Several collaborators drive the transaction model. `IPendingCertificateKeyStore`, `IPushCertificateKeyGenerator` and `IPushConfigurationTrustListEffectHandler` are registered as `TryAddSingleton` defaults by `AddServer(...)`/`OpcUaServerBuilderExtensions`, so applications can override any of them by registering their own implementation *before* calling `AddServer`. The `IPushConfigurationTransactionCoordinator` is deliberately **not** registered as a shared default: it holds mutable per-server transaction state, so each server's `ConfigurationNodeManager` owns its own instance and two servers built from one container never share (and corrupt) each other's transactions. A host that needs to observe or replace the coordinator can still register a custom `IPushConfigurationTransactionCoordinator` before `AddServer`, and it is honored as-is.
- `IPushConfigurationTransactionCoordinator` (default: a private, per-server `PushConfigurationTransactionCoordinator` owned by `ConfigurationNodeManager`) owns transaction ownership, staged-operation bookkeeping, commit/rollback ordering and `TransactionDiagnostics`.
- `IPendingCertificateKeyStore` (default: `DirectoryPendingCertificateKeyStore`) persists the private key regenerated by `CreateSigningRequest(regeneratePrivateKey: true)` until a matching `UpdateCertificate` consumes it (§7.10.10), scoped to its own sub-folder per (CertificateGroup, CertificateType) and protected with the configured `ICertificatePasswordProvider`. Tests can swap in the volatile, in-memory `InMemoryPendingCertificateKeyStore` instead.
Built-in stores implement `IPeekablePendingCertificateKeyStore`, an optional
extension of `IMatchingPendingCertificateKeyStore`. Staging uses
`TryPeekMatchingAsync` without consuming the pending key. If no pending key
matches, an upload may reuse the existing certificate's matching private key,
including stores without a pending sub-store. A mismatch is rejected at staging.
Only `ApplyChanges` claims a regenerated key with `TryTakeMatchingAsync`; the
staged certificate and claimed-key handles remain owned until the transaction
is disposed. Failed commits and later transaction rollbacks restore the claim
only while its scope is empty, never replacing a newer signing request.
`CreateSelfSignedCertificate` and `DeleteCertificate` also discard obsolete
signing requests only during commit, retaining a compensation handle.
`CancelChanges` and session close do not remove pending signing keys.
Compensation has its own bounded cancellation lifetime. Directory and hardware
operations serialize per configured scope within the process; distributed
stores use conditional compare-and-swap across replicas. Custom stores implement
the peek capability to support regenerated-key uploads without destructive
probes. The existing pending-store interfaces and their signatures are unchanged.
Without `IPeekablePendingCertificateKeyStore`, `CreateSigningRequest` rejects
`regeneratePrivateKey: true` with `Bad_NotSupported` before generating or
saving a key. Existing-key signing requests and updates remain supported.
The provider's explicit `TryTakeAsync` API remains available.
- `IPushCertificateKeyGenerator` (default: `AdditionalEntropyCertificateKeyGenerator`) generates the regenerated signing-request key pair, **genuinely mixing the caller-supplied §7.10.10 `Nonce` into the private key** (see *Method security and validation* below).
Both are also constructor parameters on `ConfigurationNodeManager`/`MainNodeManagerFactory` for applications that construct the server directly instead of through DI; omitting them lets `ConfigurationNodeManager` create its own private defaults.
Two further collaborators are internal to the stack and not replaceable: the `NamespaceMetadataRegistry` behind the Part 5 `NamespaceMetadata` members of `IConfigurationNodeManager`, and the `CertificateAlarmScheduler` that refreshes and evaluates the §7.8.3 alarms. `ConfigurationNodeManager` itself is a partial class split into one `ConfigurationNodeManager.*.cs` file per concern (Push methods, push validation, certificate slots, ApplyChanges, trust material, certificate alarms, namespace metadata).
**High-availability (distributed) transactions.** By default, each server
replica has its own PushManagement transaction. Two administrators using
different replicas could therefore stage conflicting changes.
`UseDistributedPushConfiguration()` (from `Opc.Ua.Redundancy.Server`)
replaces the two collaborators above with shared-store-backed variants. The
transaction is then shared **across the replica set**:
- `DistributedPushConfigurationTransactionCoordinator` wraps the per-server
coordinator and guards it with a compare-and-swap lease in the shared
`ISharedKeyValueStore`, following the `SharedStoreLeaseElection` pattern.
Only the replica holding the lease can start a transaction. If a replica
stops renewing the lease, for example after a crash, the lease expires and
a standby can take over.
The coordinator acquires the lease at an `await` boundary immediately
before synchronous `Stage`. This preserves the synchronous
`Stage`/`CancelChanges` contract without sync-over-async. The coordinator
releases the lease when it applies, cancels, or resets the transaction, or
when the owning Session closes.
- `SharedKeyValuePendingCertificateKeyStore` stores regenerated
`CreateSigningRequest` private keys in the shared store rather than a local
directory. This lets one replica create a signing request and another
complete its matching `UpdateCertificate`. The store scopes keys by
`(CertificateGroup, CertificateType)` and consumes each key atomically, so
only one replica succeeds in a concurrent `TryTake`. It wipes private-key
working buffers and protects each record with an `IRecordProtector`. If an
external store lacks a protector, the store fails closed.
This composes with the configured leader election. Setting `DistributedPushConfigurationOptions.RequireLeadership = true` additionally restricts transaction ownership to the elected leader; when the Kubernetes extension is configured (`UseKubernetesLeaderElection`), that leader is decided by the Kubernetes-native `Lease` already registered as the `ILeaderElection` service, reused without creating a second Kubernetes client. Distributed behaviour is opt-in — a server that does not call `UseDistributedPushConfiguration()` keeps the per-server coordinator and directory-backed pending-key store unchanged. See [High Availability](HighAvailability.md#distributed-pushmanagement-transactions-beyond-66-opt-in) for the full setup.
**Method security and validation.** `ServerConfiguration` methods enforce
the per-method SecureChannel and Role requirements in the §7.10 Method result
tables. The server checks and reports the **channel-security** requirement
separately, before it checks the **Role** requirement:
- `UpdateCertificate` (§7.10.5) and `CreateSigningRequest` (§7.10.10) transfer private-key material and require an **encrypted** SecureChannel (`SignAndEncrypt`); a weaker channel is rejected with `Bad_SecurityModeInsufficient`.
- `CreateSelfSignedCertificate` (§7.10.6), `DeleteCertificate` (§7.10.7), `GetCertificates` (§7.10.8), `GetRejectedList`, `ApplyChanges` (§7.10.9) and `CancelChanges` do not transfer a private key and accept any **authenticated** SecureChannel (`Sign` or `SignAndEncrypt`); an unauthenticated (`None`) channel is rejected with `Bad_SecurityModeInsufficient`.
- Once the channel requirement is met, a caller lacking the `SecurityAdmin` Role is rejected with `Bad_UserAccessDenied` (never conflated with the channel failure).
Passing a null `CertificateGroupId` to `GetCertificates` selects the `DefaultApplicationGroup`, consistent with the other certificate-slot Methods.
`HttpsCertificateType` uses RSA for self-signed certificate creation,
including startup provisioning and push configuration. The configured
HTTPS slot can subsequently be reloaded and activated with its private key.
`CreateSigningRequest` applies an explicit requested subject to both RSA
and ECC requests even when `regeneratePrivateKey` is false. In that case
the request retains the existing public key and subject alternative names
without changing the active certificate; a null or empty subject retains
the active certificate's subject, including when only the store can resolve it.
For regeneration, that subject is resolved before creating the new key.
If neither metadata nor an existing certificate supplies a subject, regeneration
returns `Bad_InvalidArgument` before key generation.
A malformed subject returns `Bad_InvalidArgument` before key generation or
pending-key changes; the active certificate and any earlier pending key remain
unchanged.
`CreateSigningRequest(regeneratePrivateKey: true)` additionally requires the caller to supply at least **32 bytes** of additional entropy in the `Nonce` argument (§7.10.10); a shorter or missing `Nonce` is rejected with `Bad_InvalidArgument` and leaves all state unchanged. The default `AdditionalEntropyCertificateKeyGenerator` genuinely incorporates that entropy: it instantiates a NIST SP 800-90A HMAC-DRBG from a fresh server-side cryptographic seed concatenated with the caller `Nonce`, and derives the RSA primes (via managed `BigInteger` prime generation, on every target framework) or the EC private scalar from that DRBG. Because the server seed is always present, a weak or adversarial `Nonce` can never weaken the key; a strong `Nonce` genuinely adds entropy. On .NET Framework the platform cannot import a private-only EC scalar, so genuine additional-entropy incorporation into an ECC key is unavailable there: an ECC `regeneratePrivateKey: true` request is rejected with `Bad_NotSupported` rather than silently generating a key that ignores the mandated `Nonce` (use an RSA `CertificateType`, or run the server on .NET 8 or later, to regenerate an ECC key). RSA keys remain fully nonce-derived on all frameworks.
TrustList file operations reject a closed handle with `Bad_InvalidArgument`;
another Session's live handle remains protected with `Bad_UserAccessDenied`.
`LastUpdateTime` advances only on a successful update, and successful
compensation restores the timestamp along with the previous store contents.
Decode, store, and cancellation failures still close the file and invalidate
any validation cache affected by an attempted write.
Push-configuration compensation is independent of the cancelled request.
Each rollback receives its own 30-second server-clock cancellation budget;
an earlier operation still receives a fresh budget if a later rollback
times out. Partial TrustList commits use the same policy to restore their
stores. Rollback failures are reported explicitly and do not replace the
original commit failure with success.
`DeleteCertificate`'s endpoint-reference safety check (§7.10.7: "Certificates that are referenced by EndpointDescriptions shall not be deleted. This determination happens when ApplyChanges is called.") resolves the exact certificate each active `EndpointDescription` presents **from the active certificate registry**, using its security policy and, on None endpoints, its encrypted user-token policies. It rejects the transaction with `Bad_InvalidState` at `ApplyChanges` if the deleted certificate is still referenced. Resolving from the live registry (rather than the `EndpointDescription.ServerCertificate` blob captured when the endpoints were created) ensures a certificate that was rotated after startup is still protected. A delete that is superseded within the same transaction by a `CreateSelfSignedCertificate`/`UpdateCertificate` for the same slot coalesces to the later operation (§7.10.2 ordered-queue semantics), so replacing a referenced certificate in one transaction remains allowed. A conservative net "last remaining certificate" check still runs at staging time for immediate feedback.
### Certificate-Expiration and TrustList-Staleness Alarms (OPC UA Part 12 §7.8.3)
Certificate inputs come from an owned snapshot of the active certificate
registry, including certificates originally loaded from stores. Group
selection uses the configured certificate types and the earliest UTC
expiration. Certificate replacement is reflected at the next evaluation,
rather than continuing to evaluate an old inline certificate.
Each server `CertificateGroup` exposes the two optional standard alarm
instances defined by OPC 10000-12 §7.8.3:
- **`CertificateExpired`** (`CertificateExpirationAlarmType`) becomes **active**
when the group's soonest-expiring application certificate is within its
`ExpirationLimit` (default two weeks) of `NotAfter`, or has already expired.
Severity is *Medium* while approaching expiry and *High* once expired.
- **`TrustListOutOfDate`** (`TrustListOutOfDateAlarmType`) becomes **active**
when the group's TrustList has not been updated within its `UpdateFrequency`.
A non-positive `UpdateFrequency` (the default) disables the staleness check.
`ConfigurationNodeManager` creates and wires these alarms during
`CreateAddressSpace` and starts periodic monitoring automatically once the
server is fully running (`StandardServer.OnServerStarted`), so transition
events are never emitted before the subscription infrastructure is ready.
Monitoring is stopped and drained on shutdown. All thresholds, timestamps and
the evaluation timer flow through the injected `TimeProvider`.
The alarms follow the standard OPC 10000-9 condition lifecycle: on activation
they set `ActiveState`, raise `Retain`, clear `AckedState` and report the
alarm event; a stable state never re-emits an event; the wired `Acknowledge`
method (and `SetAcknowledgedState`) clears `Retain` once the alarm is both
inactive and acknowledged. Alarm values are re-evaluated after every committed
`UpdateCertificate`/`TrustList` change, so replacing an expiring certificate
clears the alarm without waiting for the next tick.
### Optional ServerConfiguration Surface (OPC UA Part 12 §7.10.3, §7.10.13, §7.10.20)
Beyond the mandatory Push Certificate Management Methods, the
`ServerConfigurationType` (§7.10.3) defines several **Optional** members.
`ConfigurationNodeManager` exposes each of them, but — per the specification's
"expose only when configured/known" rule — only materialises the optional
address-space node when it is actually configured. When a member is not
configured, its node is suppressed entirely (it does not appear in the address
space), and every other feature keeps working unchanged.
| Member | Kind | Exposed when | Backed by |
| --- | --- | --- | --- |
| `ApplicationUri`, `ProductUri`, `ApplicationType`, `ApplicationNames` | Variables | **Always** | The `ApplicationConfiguration` (identity is always known) |
| `HasSecureElement` | Variable | `ServerConfigurationOptions.HasSecureElement` is set | The configured `bool?` value |
| `InApplicationSetup` | Variable | `ServerConfigurationOptions.InApplicationSetup` is set | The configured `bool?` value |
| `ResetToServerDefaults` | Method | An `IServerConfigurationResetProvider` is configured | The provider (§7.10.13) |
| `ConfigurationFile` | Object | An `IApplicationConfigurationFileProvider` is configured | The provider (§7.10.20) |
Everything is configured through a single `ServerConfigurationOptions` object,
either fluently, via DI, or as a constructor argument on
`ConfigurationNodeManager`/`MainNodeManagerFactory` for direct construction.
```csharp
// Fluent (recommended)
builder
.ConfigureServerConfiguration(o =>
{
o.HasSecureElement = true; // exposes HasSecureElement = true
o.InApplicationSetup = false; // exposes InApplicationSetup = false
o.ResetShutdownDelay = TimeSpan.FromSeconds(10); // SecondsTillShutdown advertised on reset
})
.WithServerConfigurationReset<MyResetProvider>() // exposes ResetToServerDefaults
.WithApplicationConfigurationFile<MyConfigFileProvider>(); // exposes ConfigurationFile
```
The reset and configuration-file providers may also be registered as plain DI
services (`services.AddSingleton<IServerConfigurationResetProvider, ...>()`);
`DependencyInjectionStandardServer` resolves them and merges them into the
options. A DI-registered provider takes precedence over one set on the options.
**`ResetToServerDefaults` (§7.10.13).** Resets the application security
configuration to its vendor-specific default state. `ConfigurationNodeManager`
owns the standard concerns and only delegates the actual reset to the injected
`IServerConfigurationResetProvider`:
- Requires an **authenticated** SecureChannel (`Sign`/`SignAndEncrypt`, else
`Bad_SecurityModeInsufficient`) and the `SecurityAdmin` Role (else
`Bad_UserAccessDenied`).
- Is rejected with `Bad_TransactionPending` while another Session owns an active
PushManagement transaction.
- **Returns its response first**, then advertises the pending shutdown
(`ServerState` = `Shutdown`, `ShutdownReason`, `SecondsTillShutdown` =
`ResetShutdownDelay`) so the Client can receive the response and learn its
credentials may no longer work, waits the grace period, and only then invokes
the provider. The deferred reset honors the server shutdown token, so a server
that stops while the reset is pending abandons it cleanly.
- Coalesces concurrent reset requests. Once an in-place reset finishes, fails,
or is cancelled by the provider, the previous status and shutdown fields are
restored before the reset completes. A real server shutdown or a subsequent
fault state is never replaced by that restoration.
```csharp
public sealed class MyResetProvider : IServerConfigurationResetProvider
{
public async ValueTask ResetToServerDefaultsAsync(CancellationToken cancellationToken)
{
// Restore the vendor default configuration; the server then restarts.
await RestoreDefaultConfigurationAsync(cancellationToken);
}
}
```
**`ConfigurationFile` (§7.10.20).** An `ApplicationConfigurationFileType` (a
`ConfigurationFileType`, §7.8.5) whose read/update flow is backed by an
`IApplicationConfigurationFileProvider`. `ConfigurationNodeManager` wires the
whole `FileType` surface (`Open`, `Read`, `Write`, `Close`, `SetPosition`,
`GetPosition`, `CloseAndUpdate`, `ConfirmUpdate`) and owns:
- **Stream lifecycle & single-writer** semantics (last open wins; an abandoned
Session's open handle is released on Session close and after the
`ActivityTimeout` elapses).
- **Security**: read requires SecurityAdmin over an encrypted channel; the
update Methods require SecurityAdmin over an authenticated channel.
- **Transaction exclusion**: opening for writing is rejected with
`Bad_TransactionPending` while another Session's transaction is active, and
while the file is open for writing new `ApplyChanges` are blocked
(§7.10.2/§7.10.20).
- **`CloseAndUpdate`** checks `VersionToUpdate` against the provider's
`CurrentVersion` (`Bad_InvalidState` on mismatch), then **validates before
applying** and applies **atomically** — an invalid configuration or a failed
apply leaves the active configuration unchanged (no partial update). When the
provider reports `RequiresConfirmation`, a non-empty `UpdateId` is returned and
the change is reverted if `ConfirmUpdate` does not arrive within the
Client-supplied revert window.
```csharp
public sealed class MyConfigFileProvider : IApplicationConfigurationFileProvider
{
public uint CurrentVersion { get; private set; } = 1;
public DateTime LastUpdateTime { get; private set; } = DateTime.UtcNow;
public bool RequiresConfirmation => false;
public ValueTask<ByteString> ReadConfigurationAsync(CancellationToken ct = default)
=> new(SerializeCurrentConfiguration());
public ValueTask ValidateConfigurationAsync(ByteString config, CancellationToken ct = default)
=> ValidateOrThrowAsync(config); // throw ServiceResultException on invalid
public ValueTask ApplyConfigurationAsync(ByteString config, CancellationToken ct = default)
=> ApplyAtomicallyAsync(config); // all-or-nothing; bump CurrentVersion/LastUpdateTime
public ValueTask ConfirmUpdateAsync(CancellationToken ct = default) => default;
public ValueTask RevertUpdateAsync(CancellationToken ct = default) => default;
}
```
The file content exchanged through the provider is the §7.8.5.1 `FileType`
stream body (a serialized `UABinaryFileDataType` whose `SupportedDataType` is
`ApplicationConfigurationDataType`) and is treated opaquely by the node manager,
so the concrete encoding is entirely the provider's responsibility.
A provider that also implements `IApplicationConfigurationFileTargetProvider`
receives the `CloseAndUpdate` `Targets` (§7.8.5.2: contents not referenced by a
target are ignored). Its target-aware `ValidateConfigurationAsync` returns an
`ApplicationConfigurationUpdatePlan`: any non-Good per-target result rejects the
whole update with `Uncertain` and those `UpdateResults`, and
`InterruptsSessions = true` defers the apply by the Client-supplied
`RestartDelayTime` so the Client receives the response first (the response then
carries the plan's `NewVersion`; a second update is rejected with
`Bad_InvalidState` until the deferred apply has run). Providers that only
implement `IApplicationConfigurationFileProvider` keep applying the whole file
immediately.
### TrustList Size Limits (OPC UA Part 12 §8.4.5)
`ServerConfiguration.MaxTrustListSize` advertises, in bytes, the largest
TrustList a Client may write (`0` = unlimited). Enforcing a truly unbounded
write would expose the server to unbounded allocation, so the server bounds
actual enforcement by a **resource-protection safety ceiling** and — crucially —
advertises the *effective* limit it actually enforces rather than a `0` that
hides a cap.
The effective, actually-enforced limit is derived from the advertised
`MaxTrustListSize` and `ServerConfigurationOptions.MaxTrustListSizeSafetyCeiling`
(default 1 MiB):
| Advertised `MaxTrustListSize` | Effective (enforced **and** advertised) limit |
| --- | --- |
| `0` (unlimited) | the safety ceiling |
| above the safety ceiling | the safety ceiling |
| finite, at or below the ceiling | the configured `MaxTrustListSize` |
The effective limit is enforced consistently — before allocation — on
`TrustList.Write` (cumulatively across chunks, overflow-safe),
`CloseAndUpdate` (the staged payload is decoded under the effective bound), and
the direct `AddCertificate` path. An oversized `Write` or `AddCertificate` is
rejected with `Bad_RequestTooLarge`. `TrustList.Read` is not limited: it
returns the TrustList the server encoded itself, and a requested `Length`
beyond the remaining data is clamped to what is left.
```csharp
builder.ConfigureServerConfiguration(o =>
{
// MaxTrustListSize = 0 (unlimited) in the ApplicationConfiguration is
// honestly advertised and enforced as this ceiling; raise it to accept
// larger TrustLists (for example many large CRLs).
o.MaxTrustListSizeSafetyCeiling = 8 * 1024 * 1024; // 8 MiB
});
```
> The legacy `TrustList` constructor overloads that take only a single
> `maxTrustListSize` (no explicit ceiling) are unchanged and fully backward
> compatible: a finite size is honored exactly (never clamped) and `0` falls
> back to the 1 MiB default. The overload that also takes a
> `maxTrustListSizeSafetyCeiling` opts into the clamping semantics above.
### Client-Side Auto-Detection of Certificate Changes
`ManagedSession` automatically subscribes to
`CertificateManager.CertificateChanges` and surfaces the three
scenarios discussed in client-side certificate management:
1. **Own (client) certificate renewal** — `ApplicationCertificateUpdated` fires from `UpdateApplicationCertificateAsync` / `ReloadApplicationCertificatesAsync`.
2. **Trust-list add/remove** — `TrustListUpdated` fires from `WriteTrustListAsync` and from a successful `TrustListTransaction.CommitAsync` whenever certificates were added or removed.
3. **CRL add/remove** — `CrlUpdated` fires from the same paths whenever CRLs were modified.
Each event is forwarded to subscribers of
`ManagedSession.ApplicationCertificateChanged` for diagnostics and for
applications that want to implement custom rotation policies.
By default
(`ManagedSession.DisableAutoReconnectOnCertificateChange` is `false`),
the managed session ALSO automatically reacts:
- **`ApplicationCertificateUpdated`** — calls
`Session.ReloadInstanceCertificateAsync` (so the next ActivateSession
is signed with the rotated client cert) and triggers a reconnect via
the state machine. The new client cert takes effect within
milliseconds rather than at the next `SecurityTokenLifetime` rekey
(Part 4 §5.5.2).
- **`TrustListUpdated` / `CrlUpdated`** — wakes a long-lived
per-session revalidation loop that:
1. Debounces a burst of events (250 ms window) on the injected
`TimeProvider`. A batch trust-list refresh or a fleet onboarding
that publishes dozens of events in a few milliseconds collapses
to a single validation on the final state.
2. Re-runs
`ICertificateValidatorEx.ValidateAsync(serverCert)` against the
server certificate currently cached on the configured endpoint.
There is exactly **one** validation in flight per session — the
loop's single-reader semantics serialise the work and signals
that arrive during a validation re-arm the next iteration so the
final trust state is always honoured exactly once after the burst
settles.
3. Calls `StateMachine.TriggerReconnect()` **only when the result
flips from valid to invalid** — sessions whose server cert is
still trusted under the new state stay connected. This keeps a
new server being onboarded into a shared client/server fleet
from forcing every session in the process to reconnect.
The revalidation loop is implemented in
`src/Opc.Ua.Client/Session/ManagedSession.CertificateChanges.cs`.
It uses a bounded `Channel<int>` of capacity 1 with
`BoundedChannelFullMode.DropWrite` so the notifier thread never
allocates a `Task` or `CancellationTokenSource` per event — duplicate
signals collapse into the pending one. The loop pattern mirrors
`RunIdentityRefreshLoopAsync` in `ManagedSession.cs` (single Task per
session, started in `SubscribeCertificateChanges`, cancelled +
awaited in `StopRevalidationLoopAsync` from `DisposeAsync`).
```csharp
using var managed = await ManagedSession.CreateAsync(
configuration, endpoint, sessionFactory);
// Auto-reconnect on cert/trust/CRL changes is on by default.
// Opt out by setting DisableAutoReconnectOnCertificateChange = true.
managed.ApplicationCertificateChanged += (sender, e) =>
{
Console.WriteLine($"observed {e.Kind} on {e.TrustList}");
};
```
Applications that need manual control (auditing, idempotency) can opt
out of auto-reconnect by setting
`ManagedSession.DisableAutoReconnectOnCertificateChange = true` and
continue calling `Session.ReloadInstanceCertificateAsync` + their own
reconnect logic. The `ApplicationCertificateChanged` event still fires
either way.
### Working with Trust-Lists
```csharp
// Register a custom trust-list at runtime
manager.RegisterTrustList(
new TrustListIdentifier("CustomDevices"),
trustedStorePath: "/opt/opcua/pki/devices/trusted",
issuerStorePath: "/opt/opcua/pki/devices/issuers");
// Open a store directly
using ICertificateStore trustedStore = manager.OpenTrustedStore(TrustListIdentifier.Peers);
using CertificateCollection certs = await trustedStore.EnumerateAsync();
// Stage a trust-list update
await using ITrustListTransaction tx = await manager.BeginUpdateAsync(TrustListIdentifier.Peers);
await tx.AddTrustedCertificateAsync(newTrustedCert);
await tx.RemoveTrustedCertificateAsync(oldThumbprint);
await tx.CommitAsync(); // Disposing before commit discards staged changes
// Read/write trust-list as a blob (GDS Push Management)
TrustListData data = await manager.ReadTrustListAsync(TrustListIdentifier.Peers);
await manager.WriteTrustListAsync(TrustListIdentifier.Peers, data);
```
The last staging operation wins for each trusted/issuer certificate thumbprint
(case-insensitive) and each identical CRL encoding. Remove-then-add performs a
real replacement, including when the certificate is already present. Required
issuer-store configuration is checked before any trusted-certificate or CRL
write. A later backing-store failure can still leave partial writes; affected
validation caches are invalidated in that case.
> **Note:** `ITrustListTransaction`/`BeginUpdateAsync` above is a local, in-process staging API on `CertificateManager`. It is unrelated to the OPC UA PushManagement transaction model (`ApplyChanges`/`CancelChanges`, see *PushManagement Transactions* above), which is driven remotely by a Client over the `ServerConfiguration` address space and governs `TrustList`/`ServerConfiguration` Methods called through `ConfigurationNodeManager`.
## Interfaces Reference
### ICertificateRegistry
Read-only access to the application's own certificates.
| Member | Description |
|--------|-------------|
| `SnapshotApplicationCertificates()` | Caller-owned snapshot of all registered application certificate entries — dispose the returned `CertificateEntryCollection` |
| `AcquireApplicationCertificateByType(NodeId)` | Caller-owned entry found by OPC UA certificate type NodeId — dispose the returned `CertificateEntry` |
| `AcquireApplicationCertificateBySecurityPolicy(string)` | Caller-owned entry found by security policy URI — dispose the returned `CertificateEntry` |
> **Certificate chain:** each `CertificateEntry` already carries its issuer chain, so there are no
> separate chain-loading methods. Use `entry.IssuerChain` for the issuers and
> `entry.GetEncodedChainBlob()` for the DER-encoded `leaf || issuers` blob.
> **Ownership:** `CertificateEntry` (and `CertificateEntryCollection`) implement `IDisposable`.
> Every accessor returns an independent, reference-counted handle that the caller **owns and
> must dispose** (a `using` is recommended). Disposing a returned entry has no effect on the
> registry's own certificates, and the registry may concurrently replace its certificates
> (e.g. a hot-update) without invalidating handles you already hold.
### ICertificateTrustListManager
Manages an extensible set of named trust-lists.
| Member | Description |
|--------|-------------|
| `TrustLists` | All registered trust-list identifiers |
| `RegisterTrustList(...)` | Register a named trust-list (trusted + optional issuer store) |
| `OpenTrustedStore(TrustListIdentifier)` | Open the trusted-certificate store |
| `OpenIssuerStore(TrustListIdentifier)` | Open the issuer-certificate store (null if none) |
| `BeginUpdateAsync(TrustListIdentifier)` | Begin a transactional trust-list update |
Well-known trust-lists: `TrustListIdentifier.Peers`, `.Users`, `.Https`, `.Rejected`.
### ICertificateValidatorEx
Validates certificates against any trust-list. Works with both stored and ephemeral (wire-parsed) certificates.
| Member | Description |
|--------|-------------|
| `ValidateAsync(CertificateCollection, TrustListIdentifier?, ...)` | Validate a chain against a trust-list |
| `ValidateAsync(Certificate, TrustListIdentifier?, ...)` | Validate a single certificate |
| `AcceptError` (property) | `Func<Certificate, ServiceResult, bool>?` — global per-error accept callback that fires for **every** validation done through this validator. Modern replacement for the legacy `CertificateValidator.CertificateValidation` event. Per-call `CertificateValidationOptions.AcceptError` (when set) takes precedence over this global hook. |
Returns `CertificateValidationResult` with `IsValid`, `StatusCode`, `Errors`, and `IsSuppressible`.
#### Migrating from the legacy `CertificateValidation` event
Set the global `AcceptError` property on `ApplicationConfiguration.CertificateManager`
(or another `ICertificateValidatorEx` instance) after application-certificate initialization.
A per-call callback overrides the global callback for that validation. The example below
accepts untrusted certificates for development only; configure trust stores in production.
```csharp
ICertificateManager manager = config.CertificateManager;
manager.AcceptError = (cert, error) =>
error.StatusCode == StatusCodes.BadCertificateUntrusted;
// Or per-call (overrides the global hook for that call only):
var options = new CertificateValidationOptions
{
AcceptError = (cert, error) =>
error.StatusCode == StatusCodes.BadCertificateUntrusted
};
CertificateValidationResult result = await manager.ValidateAsync(chain, options: options);
```
For the old-to-new event mapping, see
[certificate migration](migrate/2.0.x/certificates.md#migrating-the-certificatevalidatorcertificatevalidation-event).
Per-call validation behaviour can be overridden via
`CertificateValidationOptions`:
| Option | Description |
|--------|-------------|
| `RejectSHA1SignedCertificates` | Override the global SHA-1 rejection policy. |
| `RejectUnknownRevocationStatus` | Override the global revocation-unknown rejection policy. |
| `MinimumCertificateKeySize` | Override the minimum acceptable RSA key size. |
| `AutoAcceptUntrustedCertificates` | Override the global auto-accept policy. |
| `AcceptError` | `Func<Certificate, ServiceResult, bool>` — invoked for each suppressible error encountered. Returning `true` accepts the specific error and validation continues. Structured replacement for the legacy `CertificateValidator.CertificateValidation += handler` + mutable `e.Accept = true` pattern. |
### ICertificateLifecycle
Monitors certificate changes and expiry.
| Member | Description |
|--------|-------------|
| `CertificateChanges` | `IObservable<CertificateChangeEvent>` — subscribe for notifications |
| `UpdateApplicationCertificateAsync(...)` | Replace an app cert, notify subscribers |
| `RejectCertificateAsync(...)` | Save to rejected store (awaitable, no fire-and-forget) |
Change event kinds: `ApplicationCertificateUpdated`, `TrustListUpdated`, `CrlUpdated`, `CertificateRejected`, `CertificateExpiring`.
### ITrustListFileAccess
Read/write trust-lists as serialized blobs for GDS Push Management (Part 12 §7.5).
| Member | Description |
|--------|-------------|
| `ReadTrustListAsync(TrustListIdentifier, TrustListMasks)` | Read trust-list contents |
| `WriteTrustListAsync(TrustListIdentifier, TrustListData, TrustListMasks)` | Write trust-list contents |
### ICertificateFactory
Stateless certificate creation and parsing. Located in `Opc.Ua.Security.Certificates`.
| Member | Description |
|--------|-------------|
| `CreateFromRawData(ReadOnlyMemory<byte>)` | Parse a DER-encoded certificate |
| `ParseChainBlob(ReadOnlyMemory<byte>)` | Parse a DER-encoded chain blob |
| `CreateCertificate(string)` | Return a certificate builder |
| `CreateApplicationCertificate(...)` | Builder with OPC UA SAN extension |
| `CreateSigningRequest(...)` | Create a CSR |
| `CreateWithPEMPrivateKey(...)` | Combine cert with PEM private key |
| `CreateWithPrivateKey(...)` | Combine cert with private key from another cert |
### ICertificateIssuer
CA signing and CRL revocation. Located in `Opc.Ua.Security.Certificates`.
| Member | Description |
|--------|-------------|
| `IssueCertificate(ICertificateBuilder, Certificate)` | Sign a certificate with a CA key |
| `RevokeCertificates(...)` | Produce an updated CRL |
## Pluggable Store Backends
The rejected-certificate store limit uses zero for unlimited history and a
negative value to disable new rejected-certificate storage. Negative limits
retain existing backend behavior: Directory prunes prior history, while the
shared key/value store leaves it intact. Native X509 snapshots have
explicit owners for matching and nonmatching entries; returned matches
remain usable after the store closes.
PEM deletion atomically replaces the file with the exact remaining
contents, retaining other certificates and private-key sections without an
old tail.
Store providers are registered via `ICertificateStoreProvider`:
```csharp
public interface ICertificateStoreProvider
{
string StoreTypeName { get; }
bool SupportsStorePath(string storePath);
ICertificateStore CreateStore(ITelemetryContext telemetry);
}
```
Built-in providers:
- `DirectoryStoreProvider` — file-system certificate store (default)
- `X509StoreProvider` — Windows certificate store (`X509Store:` prefix)
- `InMemoryStoreProvider` — in-memory store for testing (`InMemory:` prefix)
- `SharedKeyValueCertificateStoreProvider` — a certificate store distributed across a redundant server set over a shared key/value backend (`kv:` prefix, store type `SharedKeyValue`); shares the trusted, issuer and rejected lists and CRLs with fail-closed record integrity. See [High Availability § Shared certificate stores](HighAvailability.md).
- `Pkcs11StoreProvider` — a hardware token, smart card or HSM addressed by an RFC 7512 `pkcs11:` URI (store type `PKCS11`); the private key is used but never leaves the device. Ships in the optional `OPCFoundation.NetStandard.Opc.Ua.Security.Pkcs11` package. See [CryptoProvider](CryptoProvider.md).
Custom providers are passed to the `CertificateManager` constructor (or via `CertificateManagerOptions.AddStoreProvider`):
Provider-aware resolution applies both when registering trust-list paths and
when validating certificates against those stores. It resolves the default
directory fallback for provider-specific URIs without replacing a configured
custom store type. Validation caches retain the selected provider's store and
release it when the validation core is retired.
The built-in directory provider is used as a fallback during path resolution,
even when listed before a custom provider. Native `CurrentUser\...` and
`LocalMachine\...` paths continue to select the X509 store.
```csharp
var manager = new CertificateManager(
telemetry,
storeProviders: [new DirectoryStoreProvider(), new MyAzureKeyVaultProvider()]);
```
## Certificate Wrapper and Reference Counting
The `Certificate` class wraps `X509Certificate2` with reference counting:
```csharp
// Certificate starts with refcount=1
using var cert = CertificateBuilder.Create("CN=Test").SetRSAKeySize(2048).CreateForRSA();
// AddRef before sharing (e.g., when store returns cached certs)
var shared = cert.AddRef(); // refcount=2
// Each Dispose decrements; inner X509Certificate2 disposed at refcount=0
shared.Dispose(); // refcount=1
cert.Dispose(); // refcount=0 → X509Certificate2.Dispose() called
```
`CertificateCollection.Dispose()` calls `Dispose()` on each member, decrementing their reference counts. Store methods like `EnumerateAsync()` call `AddRef()` on cached certificates before returning them, so disposing the returned collection does not invalidate the store's cache.
ECC secret decryption clears owned plaintext, derived keys, IVs, and decoded
temporary byte strings on success and failure, while preserving the returned
secret and data outside the encrypted segment.
## Backward Compatibility
The legacy `CertificateValidator` class,
`ICertificateValidator` interface, `CertificateValidatorAdapter`
bridge, `CertificateTypesProvider` class, and the legacy
`ApplicationConfiguration.CertificateValidator` /
`ServerBase.CertificateValidator` /
`ServerBase.InstanceCertificateTypesProvider` properties have been
removed. New code uses `CertificateManager` directly (or the
`ICertificateValidatorEx`, `ICertificateRegistry`,
`ICertificateLifecycle`, and `ITrustListFileAccess` interfaces it
implements).
The static methods on `CertificateFactory`
(`Create(ReadOnlyMemory<byte>)`, `CreateCertificate(...)`,
`CreateSigningRequest(...)`, `RevokeCertificate(...)`,
`CreateCertificateWith{,PEM}PrivateKey(...)`) are marked `[Obsolete]`
and forward to `Certificate.FromRawData(...)`,
`DefaultCertificateFactory.Instance.*`, and
`DefaultCertificateIssuer.Instance.*` respectively. The
`CertificateFactory.DefaultKeySize` / `DefaultLifeTime` /
`DefaultHashSize` constants are intentionally kept un-obsoleted because
they remain the canonical default values used across configuration
sites.
## OPC UA Specification Alignment
| Spec Area | Interface |
|-----------|-----------|
| Trust determination (Part 4 §6.1.3) | `ICertificateValidatorEx` with `TrustListIdentifier` |
| Certificate groups (Part 12) | `ICertificateTrustListManager` named trust-lists |
| ApplicationAdmin privilege (Part 12 §7.2) | `GdsRole.ApplicationAdmin` + `GdsRoleBasedIdentity.AdministeredApplicationIds` |
| KeyCredential management (Part 12 §8) | `IKeyCredentialRequestStore` / `InMemoryKeyCredentialRequestStore` |
| Authorization services (Part 12 §9) | `AuthorizationServiceState` with `GetServiceDescription` / `RequestAccessToken` |
| CreateSelfSignedCertificate (Part 12 §7.10.6) | `ConfigurationNodeManager.CreateSelfSignedCertificateAsync` |
| Audit event redaction | `AuditEvents.RedactedPrivateKeyPassword` / `RedactedPrivateKey` — secrets never appear in audit payloads |
| Push/Pull management (Part 12 §7.7) | `ICertificateLifecycle.UpdateApplicationCertificateAsync` |
| TrustListType (Part 12 §7.5) | `ITrustListFileAccess` |
| Expiry monitoring (Part 9 §5.8.17) | `CertificateLifecycleMonitor` → `CertificateExpiring` events |
| Rejected certificates | `ICertificateLifecycle.RejectCertificateAsync` (awaitable) |
| User X.509 tokens (Part 4 §5.6.3) | `ValidateAsync(..., TrustListIdentifier.Users)` |
The authoritative, per-requirement Part 12 support status — every implemented ServerConfiguration / PushManagement, TrustList, certificate-alarm, KeyCredentialService and AuthorizationService requirement linked to its source **and** its automated tests, with complete / partial / optional / unsupported marks — lives in the [GDS Conformance Matrix](GDS.md#conformance-matrix). That matrix also identifies the applicable OPC UA Facets / conformance units and states explicitly that the formal UACTT/CTT (a licensed GUI tool) is not run automatically here.
## Materializing a `Certificate` from a `CertificateIdentifier`
`CertificateIdentifier` is metadata only: `StoreType` / `StorePath` / `SubjectName` / `Thumbprint` /
`CertificateType` / `RawData` / `ValidationOptions`. It caches no `Certificate`, is not disposable, and
`Equals` compares metadata alone. `RawData` is backed by an explicit `byte[]` field whose setter derives
`SubjectName` / `Thumbprint` / `CertificateType` from the parsed bytes. `ICertificateRegistry.GetIssuersAsync`
returns `IList<CertificateIssuerReference>`, a public sealed record carrying `Certificate` plus
`CertificateValidationOptions`.
### Resolving an identifier
Use the `CertificateIdentifierResolver` static helper:
```csharp
using Opc.Ua;
using Opc.Ua.Security.Certificates;
var id = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = "%LocalApplicationData%/OPC/pki/own",
SubjectName = "CN=My Application",
Thumbprint = "9B7B...",
CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType
};
// Resolve a public-key-only certificate from the registry / inline RawData / the store.
using Certificate publicKey = await CertificateIdentifierResolver.ResolveAsync(
id,
registry: certificateManager,
needPrivateKey: false,
applicationUri: configuration.ApplicationUri,
telemetry,
ct);
// Resolve a certificate with private key (PFX, prompts the password provider).
using Certificate privateKey = await CertificateIdentifierResolver.LoadPrivateKeyAsync(
id,
passwordProvider: configuration.SecurityConfiguration.CertificatePasswordProvider,
applicationUri: configuration.ApplicationUri,
telemetry,
ct);
// Open the underlying store directly (rare — prefer the manager / resolver helpers).
using ICertificateStore store = CertificateIdentifierResolver.OpenStore(id, telemetry);
```
The resolver always returns a caller-owned, `AddRef`'d `Certificate` (or `null`). The caller is responsible for disposing it.
### When to register an in-memory certificate with the manager
If you have a `Certificate` instance that wasn't loaded from a configured store (for example, a freshly generated cert or one returned by a GDS push), persist it to a store and let the manager pick it up:
```csharp
await newCertificate.AddToStoreAsync(
id.StoreType,
id.StorePath,
passwordProvider?.GetPassword(id),
telemetry,
ct);
// Reload + register with the manager so endpoint descriptions etc.
// observe the rotation via CertificateChanges.
await ((ICertificateLifecycle)certificateManager).UpdateApplicationCertificateAsync(
id.CertificateType,
newCertificate,
issuerChain: null,
ct);
```