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