# 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.
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
x509refactor,CertificateIdentifieris metadata-only (StoreType/StorePath/SubjectName/Thumbprint/CertificateType/RawData/ValidationOptions). It no longer caches aCertificate, no longer implementsIDisposable, and theCertificateproperty,(Certificate)/(Certificate, options)/(byte[])constructors,FindAsync,LoadPrivateKey*AsyncandOpenStoreinstance methods have been removed. TheCertificateManager(viaICertificateRegistry) is now the single source of truth for materialized application certificates;CertificateIdentifierResolveris the stateless helper used to materialize aCertificatefrom 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:
ConfigurationNodeManager.ApplyChangescaptures 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 viaCertificateManager.UpdateAsync, and (c) calls the new channel-cut hook on every transport listener.ITransportListenerCertificateRotationis an optional capability interface onITransportListener.TcpTransportListenerimplements it by thumbprint-matching the per-channelServerCertificateand force-closing only affected channels (listener socket stays bound).HttpsTransportListenerimplements 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:
- Own (client) certificate renewal —
ApplicationCertificateUpdatedfires fromUpdateApplicationCertificateAsync/ReloadApplicationCertificatesAsync. - Trust-list add/remove —
TrustListUpdatedfires fromWriteTrustListAsyncand from a successfulTrustListTransaction.CommitAsyncwhenever certificates were added or removed. - CRL add/remove —
CrlUpdatedfires 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— callsSession.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 nextSecurityTokenLifetimerekey (Part 4 §5.5.2).TrustListUpdated/CrlUpdated— wakes a long-lived per-session revalidation loop that:- 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. - 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. - 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.
- Debounces a burst of events (250 ms window) on the injected
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:
- Pure metadata describing where to find a certificate (
StoreType/StorePath/SubjectName/Thumbprint/CertificateType/ValidationOptions). - A cert wrapper that owned a loaded
Certificateand implementedIDisposable.
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:
Certificateproperty and the cachedm_certificatefield — removed.IDisposabledeclaration,Dispose(),DisposeCertificate()— removed.CertificateIdentifieris no longer disposable.- Constructors taking
Certificate/Certificate, options/byte[]— removed. - Instance methods
FindAsync,LoadPrivateKeyAsync(instance),LoadPrivateKeyExAsync,OpenStore— removed. RawDatais now backed by an explicitbyte[]field (the setter still derivesSubjectName/Thumbprint/CertificateTypefrom the parsed raw bytes).Equalscompares metadata only.ICertificateRegistry.GetIssuersAsyncreturnsIList<CertificateIssuerReference>(a public sealed record carryingCertificate+CertificateValidationOptions) instead ofIList<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);