Files
UA-.NETStandard/docs/CertificateManager.md
Wolfgang Colsman 9746c62b49 Server: make TrustList.ValidateNewTrustListAsync public (#4799)
# Description

TrustList validates an uploaded TrustList before CloseAndUpdate commits
it:
every certificate is validated against the new TrustList (the uploaded
certificates and CRLs, or the current stores for lists that were not
specified), CRLs must belong to a CA of the result, and
IssuerCertificates
must be CA certificates. The check was private, so an application that
applies a TrustList by other means, for example a GDS push it receives
through its own channel, could not apply the same check first and had to
duplicate it.

The method is now public and documented; its behaviour is unchanged. A
test calls it for an accepted TrustList, for one whose issuer is missing
and for one that lists a leaf certificate as an issuer.

## Related Issues

None.

## 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.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [x] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-10-15 08:28:16 +02:00

75 KiB

CertificateManager

CertificateManager manages application certificates, trust lists, validation, and lifecycle notifications. Use Certificates for trust and store concepts, this guide for current APIs, and the certificate migration guide 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 below.

Contents

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):

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:

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, certificate push staging/commit/rollback, issuer import/compensation, rejected-store access 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.

Application-certificate push validation uses the configured manager and the group's registered trust scope (the default application group uses Peers), including instance-scoped store providers. Per OPC 10000-12, 7.10.5, the group's TrustList must already contain the issuers; the IssuerCertificates method argument is ignored for application-certificate groups. Suppressible errors are accepted for this operation only. A per-call AcceptError callback uses an isolated validation core, so it neither reuses nor populates the acceptance cache used for normal connection validation. Missing issuers and other non-suppressible errors still fail.

Custom stores also need a durable IPeekablePendingCertificateKeyStore when regenerating CSR keys. Scoped store resolution alone does not make the default directory-backed pending-key store support arbitrary storage backends. Pending keys must survive until the matching certificate is uploaded, including from a different session, as required by OPC 10000-12, 7.10.10.

Validating Certificates

// 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.

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

// 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

// 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.

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:

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:

    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:

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 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.

// 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.
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.
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.

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).

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

// 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.

An application that applies a TrustList it received by other means (for example a GDS push delivered through its own channel) can run the same pre-commit check that TrustList.CloseAndUpdate applies before it writes the stores. A null ArrayOf keeps the current store contents, an empty one replaces them with nothing. Every certificate of the resulting TrustList is validated, including the certificates retained from a store that is not replaced, and every certificate passed as issuerCertificates must be a CA:

ServiceResult check = await trustList.ValidateNewTrustListAsync(
    issuerCertificates: newIssuers,          // ArrayOf<Certificate>
    trustedCertificates: newTrusted,         // ArrayOf<Certificate>
    issuerCrls: ArrayOf<X509CRL>.Null,       // keep the current issuer CRLs
    trustedCrls: ArrayOf<X509CRL>.Null,      // keep the current trusted CRLs
    securityConfiguration,
    ct);
if (ServiceResult.IsBad(check))
{
    // Do not apply: check.StatusCode is BadCertificateInvalid with the reason.
}

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.

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.

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:

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.
  • 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.

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.

var manager = new CertificateManager(
    telemetry,
    storeProviders: [new DirectoryStoreProvider(), new MyAzureKeyVaultProvider()]);

Certificate Wrapper and Reference Counting

The Certificate class wraps X509Certificate2 with reference counting:

// 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. 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:

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:

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);