Files
UA-.NETStandard/Docs/CertificateManager.md
Marc Schier 15eb55c46e Cert rotation: force renegotiate (#3562) + auto-detect on client (#3160) (#3849)
# Description

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

## Server side (#3562)

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

Key server changes:

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

## Client side (#3160)

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

Key client/shared changes:

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

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

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

## Tests

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

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

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

## Documentation

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

## Related Issues

- Fixes #3562
- Fixes #3160

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran focused tests locally using the **UA.slnx** projects against
.net **framework 4.8** and .net **10**, and all passed. _(Full UA.slnx
test run delegated to CI.)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-08 12:19:49 +02:00

26 KiB

CertificateManager

The CertificateManager provides centralized certificate lifecycle management for OPC UA applications. It replaces the scattered certificate handling across CertificateValidator, CertificateIdentifier, CertificateTypesProvider, and CertificateFactory with a cohesive set of interfaces following the Interface Segregation Principle.

Note: Since the x509 refactor, CertificateIdentifier is metadata-only (StoreType / StorePath / SubjectName / Thumbprint / CertificateType / RawData / ValidationOptions). It no longer caches a Certificate, no longer implements IDisposable, and the Certificate property, (Certificate) / (Certificate, options) / (byte[]) constructors, FindAsync, LoadPrivateKey*Async and OpenStore instance methods have been removed. The CertificateManager (via ICertificateRegistry) is now the single source of truth for materialized application certificates; CertificateIdentifierResolver is the stateless helper used to materialize a Certificate from an identifier on demand. See Migration: CertificateIdentifier is metadata-only below.

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.

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 chain-incomplete errors only for self-test scenarios.
        error.StatusCode == StatusCodes.BadCertificateChainIncomplete
};
CertificateValidationResult devResult = await manager.ValidateAsync(
    serverCertificate,
    TrustListIdentifier.Peers,
    options);

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

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

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

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 Libraries/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();

// Transactional trust-list update (atomic commit/rollback)
await using ITrustListTransaction tx = await manager.BeginUpdateAsync(TrustListIdentifier.Peers);
await tx.AddTrustedCertificateAsync(newTrustedCert);
await tx.RemoveTrustedCertificateAsync(oldThumbprint);
await tx.CommitAsync();  // Atomic apply; disposing without commit rolls back

// Read/write trust-list as a blob (GDS Push Management)
TrustListData data = await manager.ReadTrustListAsync(TrustListIdentifier.Peers);
await manager.WriteTrustListAsync(TrustListIdentifier.Peers, data);

Interfaces Reference

ICertificateRegistry

Read-only access to the application's own certificates.

Member Description
ApplicationCertificates All registered application certificate entries
GetApplicationCertificate(NodeId) Find by OPC UA certificate type NodeId
GetInstanceCertificate(string) Find by security policy URI
GetEncodedChainBlob(string) DER-encoded cert+chain for wire transmission

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 once on ApplicationConfiguration.CertificateValidator (or any ICertificateValidatorEx instance) and a single delegate handles every validation:

// Before (legacy event):
config.CertificateValidator.CertificateValidation += (s, e) =>
{
    if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) { e.Accept = true; }
};

// After (modern global hook on ICertificateValidatorEx):
config.CertificateValidator.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);

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

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)

Custom providers are passed to the CertificateManager constructor:

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.

Backward Compatibility

All migration is complete; 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)

Migration: CertificateIdentifier is metadata-only

What changed

CertificateIdentifier used to play two roles:

  1. Pure metadata describing where to find a certificate (StoreType / StorePath / SubjectName / Thumbprint / CertificateType / ValidationOptions).
  2. A cert wrapper that owned a loaded Certificate and implemented IDisposable.

The dual role caused recurring lifecycle bugs (stale caches surviving rotations, identifier disposal racing the registry, Thumbprint setter throwing on cache replacement, etc.). The x509 branch removes the second role:

  • Certificate property and the cached m_certificate field — removed.
  • IDisposable declaration, Dispose(), DisposeCertificate() — removed. CertificateIdentifier is no longer disposable.
  • Constructors taking Certificate / Certificate, options / byte[] — removed.
  • Instance methods FindAsync, LoadPrivateKeyAsync (instance), LoadPrivateKeyExAsync, OpenStore — removed.
  • RawData is now backed by an explicit byte[] field (the setter still derives SubjectName / Thumbprint / CertificateType from the parsed raw bytes).
  • Equals compares metadata only.
  • ICertificateRegistry.GetIssuersAsync returns IList<CertificateIssuerReference> (a public sealed record carrying Certificate + CertificateValidationOptions) instead of IList<CertificateIdentifier>.

How to materialize a Certificate from a CertificateIdentifier

Use the new 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.

Common before / after migration patterns

Before (legacy) After (resolver / manager)
var id = new CertificateIdentifier(cert); var id = new CertificateIdentifier { Thumbprint = cert.Thumbprint, SubjectName = cert.Subject, CertificateType = CertificateIdentifier.GetCertificateType(cert) }; (caller owns cert)
var id = new CertificateIdentifier(rawDataBytes); var id = new CertificateIdentifier { RawData = rawDataBytes }; (RawData setter derives the other fields)
id.Certificate read CertificateIdentifierResolver.ResolveAsync(id, ...) or registry.GetApplicationCertificate(id.CertificateType)?.Certificate
id.Certificate = cert; write Drop the assignment. The cert is owned by the manager registry (use ICertificateLifecycle.UpdateApplicationCertificateAsync) or by a local variable in the calling method.
await id.FindAsync(true, applicationUri, ...) await CertificateIdentifierResolver.LoadPrivateKeyAsync(id, passwordProvider, applicationUri, telemetry, ct)
await id.LoadPrivateKeyExAsync(passwordProvider, ...) await CertificateIdentifierResolver.LoadPrivateKeyAsync(id, passwordProvider, applicationUri, telemetry, ct)
id.OpenStore(telemetry) CertificateIdentifierResolver.OpenStore(id, telemetry)
id.DisposeCertificate(); id.Dispose(); Drop. The identifier owns nothing disposable. Dispose certificates returned by the resolver instead.
IList<CertificateIdentifier> from GetIssuersAsync; issuers[i].Certificate IList<CertificateIssuerReference>; issuers[i].Certificate (record's Certificate field, caller-owned)

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