Files
Marc SchierandCopilot 4cbcec6e23 Update NuGet dependencies to latest and version ASP.NET Core per target framework (#4223)
# Description

Refreshes the centrally-managed NuGet dependencies in
`Directory.Packages.props` to their latest stable versions, and changes
how the ASP.NET Core packages are pinned so their version follows the
target framework.

## ASP.NET Core packages are now versioned per TFM

`Microsoft.AspNetCore.Authentication.Certificate`,
`Microsoft.AspNetCore.Authentication.JwtBearer`,
`Microsoft.AspNetCore.Mvc.Testing` and `Microsoft.AspNetCore.TestHost`
ship one band per .NET major, carry no `netstandard2.0` asset, and do
not roll forward across majors — a `net8.0` project simply cannot
consume the `10.0.x` band. A single central pin therefore had to sit on
the oldest supported band and hold *every* consumer there, which is why
these sat at `8.0.28` while `10.0.10` was current.

They are now selected from `$(TargetFramework)`, mirroring the existing
MQTTnet split:

| TFM | Version |
|---|---|
| `net8.0` | `8.0.29` |
| `net9.0` | `9.0.18` |
| everything else | `10.0.10` |

The last group is the fallback rather than an explicit `net10.0` match,
so a TFM the file does not enumerate — a future `net11.0`, or the
`net10.0` shell that legacy `netstandard2.0`/`netstandard2.1`
`$(CustomTestTarget)` builds fall back to — still resolves a version.
Verified against the generated `project.assets.json` that each TFM of
`Opc.Ua.Bindings.Https` really does resolve its own band.

## Version changes

`Crdt`/`Crdt.Transport` and the `RaftCs` family 1.1.2 · `Dekaf` 1.11.2 ·
`JsonSchema.Net` 9.4.0 · `Microsoft.Bcl.TimeProvider`, the
`Microsoft.Extensions.*` set and
`System.Composition.AttributedModel`/`System.Formats.Asn1`/`System.Text.Json`/`System.Threading.Channels`/`System.Threading.RateLimiting`
10.0.10 · `Microsoft.Extensions.Http.Resilience` and
`Microsoft.Extensions.TimeProvider.Testing` 10.8.0 ·
`Microsoft.NET.Test.Sdk` 18.8.1 · `Microsoft.SourceLink.*` 10.0.301 ·
`Nerdbank.GitVersioning` 3.10.91 · `Serilog` 4.4.0 ·
`System.CommandLine` 2.0.10 · `Avalonia`/`Avalonia.Desktop` 12.1.1 ·
`MQTTnet`/`MQTTnet.Server` 5.2.0.1603 (modern band only) ·
`Roslynator.*` 4.16.0 · `TUnit` 1.64.6.

The two `System.Collections.Immutable` `VersionOverride`s in the schema
test projects move to 10.0.10 to stay ahead of what `JsonSchema.Net`
requires.

## Removed: the DotNext pin

`DotNext` 5.26.3 was pinned centrally but no project references it. The
`docs/migrate/2.0.x/packages.md` row claiming `src/Opc.Ua.Lds.Server`
consumes it was also wrong — that project references only
`Makaretu.Dns.Multicast` and
`Microsoft.Extensions.Hosting.Abstractions`. Both are corrected.

## BouncyCastle held at 2.6.2 — CI caught a real regression

The first push of this branch took `BouncyCastle.Cryptography` to 2.7.0.
CI failed `Opc.Ua.Core.Tests` on **net48 only**
(`VerifyPEMSupportDirectoryStoreAsync` expecting 3 certificates and
getting 0), and the cause turned out to be a genuine behavioural break
rather than a flaky test.

BouncyCastle is referenced **only on net472/net48**, where it backs
`PEMReader`/`PEMWriter` in `Opc.Ua.Security.Certificates`. 2.7.0
tightened `Asn1.X509.TbsCertificateStructure` to throw:

```
certificate issuer is an empty distinguished name
```

`OpenSsl.PemReader.ReadObject()` therefore aborts on the *first* entry
of a chain whose issuer DN is empty. `ImportPublicKeysFromPEM` has no
per-object recovery, so the entire file is discarded and the store
enumerates nothing.

This is a **runtime** behaviour change, not a test artefact: .NET's own
PEM support on net8.0+ still accepts such a certificate, so taking 2.7.0
would let the same trusted-peer store load fine on net8.0+ and come back
**empty** on .NET Framework. Whether the stack *should* reject these
certificates on every TFM is a fair question, but it is a
security-behaviour decision that does not belong in a dependency refresh
— so the pin is documented in place with that context for a follow-up.

Confirmed by bisecting the package version against the net48 tests
(2.6.2 passes, 2.7.0 fails) and by reading the actual PEM asset with
both versions to capture the exact throw site.

## Deliberately left alone

Every existing pin with a documented reason is preserved, and each was
re-checked rather than assumed:

- **Roslyn compat band** (`roslyn.props`) — `Microsoft.CodeAnalysis.*`
stays on the pinned band and
`System.Collections.Immutable`/`System.Reflection.Metadata` stay on
`$(RoslynRuntimeVersion)`. Raising these is what re-breaks the analyzer
`ImmutableArray<T>` identity the file warns about, and it would raise
the minimum compiler that will load the shipped analyzers.
`Microsoft.CodeAnalysis.Analyzers` stays at 4.14.0 for the same reason.
- **`Microsoft.AspNetCore.*` 2.3.11** — last band with a
`netstandard2.0` asset; already latest.
- **`PacketDotNet` / `SharpPcap`** — SDL native-code pins; already
latest stable.
- **`System.Net.Http` 4.3.4 / `System.Text.RegularExpressions` 4.3.1 /
`System.Private.Uri` 4.3.2** — transitive CVE remediation pins; already
latest.
- **`MQTTnet` 4.3.7.1207** for `net472`/`net48`/`netstandard2.1` —
MQTTnet 5.x dropped those TFMs.
- **`BouncyCastle.Cryptography` 2.6.2** — see the section above.
- **`Moq`** — pinned.
- **Prerelease-only upgrades skipped** — `BenchmarkDotNet`
0.16.0-preview, `NUnit` 5.0.0-beta, `NUnit.Console` 4.0.0-beta,
`EmbedIO` 4.0.0-rc1, `Newtonsoft.Json` 13.0.5-beta, `PacketDotNet`
1.4.9-pre, and others.

## Overlap with #4195 — please note

**OpenUSD and ModelContextProtocol are intentionally not touched here.**
#4195 already moves OpenUSD to `0.6.0-alpha` together with the restored
render smoke job and the `OpenUsd.Rendering.Silk.Vulkan` package that
the bump actually depends on, and separately moves MCP to `2.1.0`.
Rev'ing either in this PR would duplicate that work, and would risk
silently downgrading MCP if the two landed in the wrong order. They are
left to #4195.

There is still a small expected overlap in `Directory.Packages.props`:
#4195 also bumps `Microsoft.Extensions.Configuration.Abstractions`,
`Microsoft.Extensions.Hosting.Abstractions` and `System.Text.Json` to
10.0.10 — the same values as here, so whichever merges second resolves
to an identical line.

## The one code change

Roslynator 4.16 adds **RCS1258**, which flags `TypeAttributes.Public |
TypeAttributes.Class` in `ComplexTypeBuilder`. It is correct:
`TypeAttributes.Class` is `0`, so the flag is a no-op, and the
`DefineEnum` call directly above already passes `TypeAttributes.Public`
alone. Dropping it is a verified semantic no-op (`Public | Class ==
Public`).

## Related Issues

_No tracking issue — routine dependency maintenance, in the same spirit
as the weekly Dependabot NuGet updates configured in
`.github/dependabot.yml`._

## 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.<br/>_No new tests: this
changes dependency versions, not behaviour. The existing suite is the
proof, and the one code change is a verified no-op._
- [x] I have added all necessary
documentation.<br/>_`docs/migrate/2.0.x/packages.md` — corrected version
rows, removed the wrong `DotNext` row, added a section explaining the
per-TFM ASP.NET Core scheme._
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.<br/>_Full `UA.slnx` build across all six TFMs
(`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`):
**0 warnings, 0 errors**. `dotnet restore`: 0 warnings. `dotnet pack`: 0
warnings, 0 errors, package validation passes._
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.<br/>_See
the verification note below._
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

### Update after the first CI run

The initial push failed one CI job — `Opc.Ua.Core.Tests` on net48, 2 PEM
tests. That was **not** flakiness: it was a real break from BouncyCastle
2.7.0, root-caused and fixed by holding that package at 2.6.2 (see the
section above). `Opc.Ua.Core.Tests` on net48 now runs **4161 passed / 0
failed** locally, and the full six-TFM build is still 0 warnings / 0
errors.

### Review feedback

Both review threads are addressed in `78bdd0ea3`:

- The **package table's third column was stale** in more rows than the
ones flagged. Every entry is now checked against the actual
`PackageReference` items — `System.Threading.Channels` belongs to
`Opc.Ua.Core`/`Core.Diagnostics`/`PubSub.Diagnostics` (not the LDS
server), the `Microsoft.CodeAnalysis.*` rows come from the
generator/analyzer variant targets under `tools/`, and
`Microsoft.CodeAnalysis.Analyzers` plus `System.Reflection.Metadata`
have no direct reference at all and are labelled as central pins. The
column is renamed **"Referenced by"**, which is what it actually records
and what makes it verifiable.
- The **BouncyCastle pin now references
[#4224](https://github.com/OPCFoundation/UA-.NETStandard/issues/4224)**,
which carries the decision on whether the stack should reject
empty-issuer certificates on every TFM, whether
`ImportPublicKeysFromPEM` should recover per entry, and whether the
expired `Test_chain.pem` asset should be regenerated.

## Verification note on the test run

The full net10.0 run covered ~48,000 tests. 15 failed, and each was
individually shown **not** to be caused by this change:

- **4 in `Opc.Ua.Core.Diagnostics.Tests`** (3×
`ReadChunkDecryptsStackEncryptedSymmetricChunk`,
`PrivateRequestSummaryFormatsKnownRequestKinds`) — reproduced
**identically at `HEAD` with the changes stashed**. Pre-existing.
- **8 across `Client`, `Di`, `WotCon.Samples`, `Server`** — all pass
when their project is run in isolation; they only fail under the
parallelism of a full-solution run. The machine also had ~45 orphaned
test processes holding sockets, which were cleared.
- **3 in `Gds`/`Stress`** — the log shows socket port exhaustion (`Only
one usage of each socket address...`). The `CertRotationLiveTests` pair
passes when run standalone, and that project's net10.0 package closure
contains **none** of the bumped packages (`BouncyCastle` is
`net472`/`net48`-only, so it cannot reach a net10.0 test at all).

The AOT/TUnit suite (`Opc.Ua.Aot.Tests`) was A/B tested: TUnit **1.58**
produced the same intermittent 0–2 failures as 1.64, confirming
pre-existing flakiness rather than a regression. On **1.64.6** it ran
**129/129 twice**.

`net48` was built and exercised via `CustomTestTarget=net48`; the
`MSB3277`/`NU1702` warnings it surfaces in `Opc.Ua.Stress.Tests` are
structural to that mode (the project builds `net8.0` while consuming
`net48` outputs) and pre-date this change — the `net462` asset's
assembly version simply tracks the patch number, so `10.0.0.9` became
`10.0.0.10`.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 2be367ab-e62d-4141-a4df-e5b2ca3cd126
2026-08-10 18:18:27 +00:00

44 KiB
Raw Permalink Blame History

Certificates

All required application certificates for OPC UA are created at the first start of each application in a directory or OS-level certificate store and remain in use until deleted from the store.

The UA stack allows also for using CA issued application certificates and remote certificate store and trust list management with a Global Discovery Server using Server Push.

Private keys do not have to live in a file or an OS certificate store. A key can be held in a TPM, a smart card, an HSM or a remote key service and never enter process memory. See CryptoProvider for pluggable cryptography and hardware-held keys, and the OPCFoundation.NetStandard.Opc.Ua.Security.Pkcs11 package for PKCS#11 tokens.

Certificate stores

The layout of the certificate stores for sample applications which store the certificates in the file system follow the recommended layout in the specification, where certificates are stored in a certs folder, private keys under a private folder and revocation lists under a crl folder with a <root> folder called pki.

The UA .NET Standard stack supports the following certificate stores:

  • The Application store <root>/ownwhich contains private keys used by the application.

  • The Issuer store <root>/issuerwhich contains certificates which are needed for validation, for example to complete the validation of a certificate chain. A certificate in the Issuer store is not trusted!

  • The Trusted store <root>/trustedwhich contains certificates which are trusted by the application. The certificates in this store can either be self signed, leaf, root CA or sub CA certificates. The most common use case is to add a self signed application certificate to the Trusted store to establish trust with that application. If the application certificate is the leaf of a chain, the trust can be established by adding the root CA, a sub CA or the leaf certificate itself to the Trusted store. Each of the options enables a different set of trusted certificates. A trusted Root CA or Sub CA certificate is used as the trust anchor for the certificate chain, which means any leaf certificate with a chain which contains the Root CA and Sub CA certificate is trusted, but the specification still mandates the validation of the whole chain. For the chain validation any certificate in the chain except the leaf certificate must be available from the Issuer store.

    If only the leaf certificate is in the Trusted store and the rest of the chain is stored in the Issuer store, then only the leaf certificate is trusted. As an example, to trust an application certificate that is issued by a Root CA, only the Root CA certificate is required in the Trusted store to establish trust to all application certificates issued by the CA. This option can greatly simplify the management of OPC UA Clients and Servers because only one certificate needs to be distributed across all systems.

  • The Rejected store <root>/rejected which contains certificates which have been rejected. This store is provided as a convenience for the administrator of an application to allow to copy an untrusted certificate from the Rejected to the Trusted store to establish trust with that application.

  • The Issuer User store <root>/issuerUser which contains user certificates which are used to validate user certificates.

  • The Trusted User store <root>/trustedUser which contains user certificates which are trusted by an application. To establish trust, the same rules apply as explained for the Trusted and the Issuer store.

  • The Issuer Https store <root>/issuerHttps which contains https certificates which are used to validate https connection certificates.

  • The Trusted Https store <root>/trustedHttps which contains https certificates which are trusted by an application. To establish trust, the same rules apply as explained for the Trusted and the Issuer store.

X509Store on Windows

Starting with Version 1.5.xx of the UA .NET Standard Stack the X509Store supports the storage and retrieval of CRLS, if used on the Windows OS. This enables the usage of the X509Store instead of the Directory Store for stores requiring the use of crls, e.g. the issuer or the directory Store.

Certificate and CertificateCollection Types

The stack uses the Certificate wrapper type (in Opc.Ua.Security.Certificates) instead of X509Certificate2 directly. Certificate wraps X509Certificate2 with reference counting for safe shared ownership — the inner X509Certificate2 is disposed only when the last reference is released. Use Certificate.AddRef() before sharing and Dispose() to release.

CertificateCollection implements IList<Certificate> and IDisposable. Disposing a collection decrements the reference count on each member. Store methods like EnumerateAsync() call AddRef() on cached certificates, so disposing the returned collection is safe.

For interop with .NET APIs that require X509Certificate2, use certificate.AsX509Certificate2() which creates a copy that the caller must dispose.

See CertificateManager.md for details.

Windows .NET applications

By default the self signed certificates are stored in a X509Store called CurrentUser\UA_MachineDefault. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc). The trusted, issuer and rejected stores remain in a folder called OPC Foundation\pki with a root folder which is specified by the SpecialFolder variable %CommonApplicationData%. On Windows 7/8/8.1/10 this is usually the invisible folder C:\ProgramData.

Windows UWP applications

By default the self signed certificates are stored in a X509Store called CurrentUser\UA_MachineDefault. The certificates can be viewed or deleted with the Windows Certificate Management Console (certmgr.msc).

The trusted, issuer and rejected stores remain in a folder called OPC Foundation\pki in the LocalState folder of the installed universal windows package. Deleting the application state also deletes the certificate stores.

.NET Core applications on Windows, Linux, iOS etc

The self signed certificates are stored in a folder called OPC Foundation/pki/own with a root folder which is specified by the SpecialFolder variable %LocalApplicationData% or in a X509Store called CurrentUser\My, depending on the configuration. For best cross platform support the personal store CurrentUser\My was chosen to support all platforms with the same configuration. Some platforms, like macOS, do not support arbitrary certificate stores.

The trusted, issuer and rejected stores remain in a shared folder called OPC Foundation\pki with a root folder specified by the SpecialFolder variable %LocalApplicationData%. Depending on the target platform, this folder maps to a hidden locations under the user home directory.

Certificate Validation

The OPC UA .NET Standard Stack validates certificates according to the OPC UA specification. The new CertificateManager provides centralized certificate management with trust-list-scoped validation, lifecycle monitoring, and pluggable store backends (see CertificateManager.md). The legacy CertificateValidator class remains supported via a backward compatibility adapter. This section describes the certificate validation workflow, configuration settings, and how to customize the validation process.

Validation Workflow

The certificate validation process follows these steps:

  1. Structural Check: Any certificate in the chain whose subject or issuer is an empty distinguished name is rejected immediately with the non-suppressible Bad_CertificateInvalid. An empty name is an empty RDNSequence: it identifies nothing, and two unrelated issuers become indistinguishable, so such a certificate can never take part in a trust decision. RFC 5280 §4.1.2.4 requires a non-empty issuer, and §4.1.2.6 only permits an empty subject for an end entity carrying a critical subjectAltName — which a CA may never do. This check runs before the trust-list lookup and the chain build so the outcome is identical on every target framework, rather than depending on whether the platform's X.509 parser happens to accept the certificate.

  2. Pre-validation Check: If the certificate was previously validated and UseValidatedCertificates is enabled, the validation is skipped.

  3. Trust Check: The validator checks if the certificate is explicitly trusted by searching in:

    • The trusted certificate list (TrustedPeerCertificates.TrustedCertificates) - An in-memory collection of explicitly trusted certificates
    • The trusted certificate store (TrustedPeerCertificates.StorePath) - A file system directory or X509Store containing trusted certificates
    • The application's own certificate collection (ApplicationCertificates) - The certificates used by the application itself

    See Certificate List Configuration for details on how these lists are populated.

  4. Issuer Chain Validation: For certificates issued by a CA, the validator builds and validates the complete certificate chain. See Chain Building Process for detailed technical documentation.

  5. Certificate Properties Validation: The validator checks:

    • Certificate expiration dates (NotBefore/NotAfter)
    • Key usage flags (DigitalSignature for ECDSA, DataEncipherment for RSA)
    • Minimum key size requirements
    • Signature algorithm strength (e.g., rejecting SHA-1 if configured)
    • Certificate signature validity
  6. Domain Validation: If an endpoint is provided, the validator checks that the certificate contains the endpoint's domain name in its Subject Alternative Names.

  7. Application URI Validation: Verifies that the certificate contains the expected Application URI in the Subject Alternative Name extension.

  8. Error Handling: If validation errors occur, they are classified as either:

    • Suppressible errors: Can be accepted via the CertificateValidation event callback
    • Non-suppressible errors: Always cause validation to fail
  9. Rejected Certificate Storage: Failed certificates are saved to the rejected certificate store for administrator review.

Certificates with an empty distinguished name are also skipped when a PEM file is read, so they never reach a trust list in the first place. PEMReader.ImportPublicKeysFromPEM drops only the offending entry and returns the remaining certificates, so one malformed certificate cannot empty an otherwise usable trust list. DistinguishedNameUtils.HasEmptyDistinguishedName exposes the same check to applications.

Chain Building Process

This section provides detailed technical documentation of the certificate chain building and validation algorithm implemented in CertificateValidator.GetIssuersNoExceptionsOnGetIssuerAsync().

Overview

The chain building process constructs a complete certificate chain from a leaf certificate up to a self-signed root CA certificate. The algorithm searches through multiple certificate stores in a specific priority order and performs revocation checking at each step.

Chain Building Algorithm Diagram

┌─────────────────────────────────────────────────────────────────┐
│                     Start Chain Building                        │
│                   Input: Certificate Chain                      │
└────────────────────────────┬────────────────────────────────────┘
                             │
                             ▼
                ┌────────────────────────────┐
                │  Current = certificates[0] │
                │  (Leaf Certificate)        │
                └────────────┬───────────────┘
                             │
                             ▼
                ┌────────────────────────────┐
                │  Create untrusted list     │
                │  from certificates[1..n]   │
                └────────────┬───────────────┘
                             │
                ┌────────────▼───────────────┐
                │ Loop: Find Issuer Chain    │
                └────────────┬───────────────┘
                             │
                ┌────────────▼───────────────────────────┐
                │ Is Current certificate self-signed?    │
                └─┬─────────────────────────────────┬────┘
                  │ YES                             │ NO
                  │                                 │
                  ▼                                 ▼
         ┌────────────────┐          ┌─────────────────────────────┐
         │ Chain Complete │          │ Search for Issuer (Step 1)  │
         │ Return Result  │          │ Location: Trusted Store     │
         └────────────────┘          └──────────┬──────────────────┘
                                                 │
                                     ┌───────────▼──────────────┐
                                     │ Issuer Found in Trusted? │
                                     └─┬─────────────────────┬──┘
                                       │ YES                 │ NO
                                       │                     │
                                       ▼                     ▼
                          ┌────────────────────┐  ┌──────────────────────────┐
                          │ Mark as Trusted    │  │ Search for Issuer (Step 2)│
                          │ Check Revocation   │  │ Location: Issuer Store    │
                          └──────┬─────────────┘  └────────┬─────────────────┘
                                 │                         │
                                 │             ┌───────────▼──────────────┐
                                 │             │ Issuer Found in Issuer?  │
                                 │             └─┬─────────────────────┬──┘
                                 │               │ YES                 │ NO
                                 │               │                     │
                                 │               ▼                     ▼
                                 │    ┌──────────────────┐  ┌──────────────────────────┐
                                 │    │ Check Revocation │  │ Search for Issuer (Step 3)│
                                 │    └────────┬─────────┘  │ Location: Untrusted Certs │
                                 │             │            └────────┬─────────────────┘
                                 │             │                     │
                                 │             │         ┌───────────▼──────────────┐
                                 │             │         │ Issuer Found in Chain?   │
                                 │             │         └─┬─────────────────────┬──┘
                                 │             │           │ YES                 │ NO
                                 │             │           │                     │
                                 │             │           ▼                     ▼
                                 │             │  ┌──────────────────┐  ┌──────────────┐
                                 │             │  │ Check Revocation │  │ Chain Broken │
                                 │             │  └────────┬─────────┘  │ Return Result│
                                 │             │           │            └──────────────┘
                                 │             │           │
                                 └─────────────┴───────────┘
                                               │
                                 ┌─────────────▼──────────────┐
                                 │ Check for Circular Chain   │
                                 │ (Duplicate Thumbprint)     │
                                 └─┬────────────────────────┬─┘
                                   │ DUPLICATE              │ UNIQUE
                                   │                        │
                                   ▼                        ▼
                          ┌────────────────┐    ┌──────────────────────┐
                          │ Chain Complete │    │ Add Issuer to List   │
                          │ Return Result  │    │ Current = Issuer Cert│
                          └────────────────┘    └──────────┬───────────┘
                                                            │
                                                            │
                                        ┌───────────────────┘
                                        │ Loop Back to Top
                                        └──────────────────────┐
                                                               │
                                                               ▼
                                                    (Continue Building Chain)

Detailed Algorithm Steps

Step 1: Initialization
INPUT:
  - certificates: CertificateCollection (certificate chain from peer)
  - issuers: List<CertificateIdentifier> (output list, initially empty)
  - validationErrors: Dictionary<Certificate, ServiceResultException> (output)

INITIALIZE:
  - isTrusted ← false
  - current ← certificates[0]  // Leaf certificate
  - untrustedCollection ← certificates[1..n]  // Additional certs from peer
Step 2: Iterative Chain Building Loop
WHILE (issuer is found) DO:

  // Exit condition: Self-signed certificate reached
  IF (IsSelfSigned(current)) THEN
    BREAK  // Chain is complete
  END IF

  // Step 2.1: Search in Trusted Certificate Store
  issuer ← FindIssuer(
    certificate: current,
    location: TrustedCertificateList + TrustedCertificateStore,
    checkRevocation: true
  )

  IF (issuer != null) THEN
    isTrusted ← true  // Chain ends in trusted store
    revocationStatus ← CheckCRL(issuer, current)
    validationErrors[current] ← revocationStatus
    GOTO Step_2.4
  END IF

  // Step 2.2: Search in Issuer Certificate Store
  issuer ← FindIssuer(
    certificate: current,
    location: IssuerCertificateList + IssuerCertificateStore,
    checkRevocation: true
  )

  IF (issuer != null) THEN
    revocationStatus ← CheckCRL(issuer, current)
    validationErrors[current] ← revocationStatus
    GOTO Step_2.4
  END IF

  // Step 2.3: Search in Untrusted Certificates (from peer)
  issuer ← FindIssuer(
    certificate: current,
    location: untrustedCollection,
    checkRevocation: true
  )

  IF (issuer == null) THEN
    BREAK  // Chain building failed - no issuer found
  END IF

  // Step 2.4: Circular Chain Detection
  FOR EACH existingIssuer IN issuers DO
    IF (existingIssuer.Thumbprint == issuer.Thumbprint) THEN
      BREAK LOOP  // Circular chain detected
    END IF
  END FOR

  // Step 2.5: Add Issuer to Chain
  issuers.Add(issuer)
  current ← LoadCertificate(issuer)

END WHILE

RETURN isTrusted
Step 3: Issuer Matching Algorithm

The FindIssuer() function uses the following matching criteria:

FUNCTION FindIssuer(certificate, location, checkRevocation):

  // Extract issuer information from certificate
  subjectName ← certificate.IssuerName
  authorityKeyId ← ExtractAuthorityKeyIdentifier(certificate)
  serialNumber ← ExtractSerialNumber(certificate)

  // Search all certificates in location
  FOR EACH candidate IN location DO

    // Basic matching criteria
    IF (candidate.SubjectName != subjectName) THEN
      CONTINUE  // Subject name must match issuer name
    END IF

    // Check if issuer is allowed (not end-entity cert)
    IF (NOT IsIssuerAllowed(candidate)) THEN
      CONTINUE  // Must have CA capabilities
    END IF

    // Optional: Match by serial number
    IF (serialNumber != null AND candidate.SerialNumber != serialNumber) THEN
      CONTINUE
    END IF

    // Optional: Match by Authority Key Identifier
    IF (authorityKeyId != null) THEN
      subjectKeyId ← candidate.SubjectKeyIdentifier
      IF (subjectKeyId != authorityKeyId) THEN
        CONTINUE
      END IF
    END IF

    // Candidate matches - perform revocation check
    IF (checkRevocation) THEN
      revocationStatus ← CheckCRL(candidate, certificate)
      IF (revocationStatus == Revoked) THEN
        RETURN (candidate, RevocationError)
      END IF
    END IF

    RETURN (candidate, revocationStatus)
  END FOR

  RETURN (null, null)  // No matching issuer found
END FUNCTION
Step 4: Certificate Revocation List (CRL) Checking
FUNCTION CheckCRL(issuer, certificate):

  // Only check if store supports CRL operations
  IF (store.SupportsCRL()) THEN

    crlStatus ← store.IsRevoked(issuer, certificate)

    CASE crlStatus OF
      StatusCodes.Good:
        RETURN null  // Not revoked

      StatusCodes.BadCertificateRevoked:
        IF (IsCertificateAuthority(certificate)) THEN
          RETURN BadCertificateIssuerRevoked
        ELSE
          RETURN BadCertificateRevoked
        END IF

      StatusCodes.BadCertificateRevocationUnknown:
        IF (IsCertificateAuthority(certificate)) THEN
          statusCode ← BadCertificateIssuerRevocationUnknown
        ELSE
          statusCode ← BadCertificateRevocationUnknown
        END IF

        // Check if error should be suppressed
        IF (RejectUnknownRevocationStatus AND
            NOT HasValidationOption(SuppressRevocationStatusUnknown)) THEN
          RETURN statusCode
        END IF
        RETURN null  // Suppressed

      StatusCodes.BadNotSupported:
        RETURN null  // CRL not supported by store
    END CASE
  END IF

  RETURN null
END FUNCTION

X509Chain Validation

After building the issuer chain, the validator uses .NET's X509Chain to verify cryptographic signatures and certificate properties:

// Configure chain policy
policy ← new X509ChainPolicy()
policy.RevocationMode ← NoCheck  // Already checked via CRL
policy.RevocationFlag ← EntireChain
policy.VerificationFlags ← ConfigureFromValidationOptions(issuers)

// Add all found issuers to extra store
FOR EACH issuer IN issuers DO
  policy.ExtraStore.Add(issuer.Certificate)
END FOR

// Build and validate chain
chain ← new X509Chain()
chain.ChainPolicy ← policy
chain.Build(certificate)

// Process chain status
FOR EACH chainStatus IN chain.ChainStatus DO
  ProcessChainStatus(chainStatus)
END FOR

// Verify chain matches issuers
IF (chain.ChainElements.Count != issuers.Count + 1) THEN
  chainIncomplete ← true
END IF

// Validate each chain element
FOR EACH element IN chain.ChainElements DO
  ValidateChainElement(element)
END FOR

Chain Validation Results

The chain building process returns:

  1. isTrusted: true if any issuer was found in the trusted store, false otherwise
  2. issuers: List of all issuer certificates in the chain (leaf to root, excluding the leaf itself)
  3. validationErrors: Dictionary mapping certificates to their revocation status errors

These results are then used by the main validation logic to determine if the certificate should be accepted or rejected.

Key Behaviors

  1. Search Priority: Trusted store → Issuer store → Untrusted collection (from peer)
  2. Trust Anchoring: If any issuer is found in the trusted store, the entire chain is considered to have a trusted anchor
  3. CRL Checking: Performed during chain building if the store supports it
  4. Circular Chain Detection: Prevents infinite loops by checking for duplicate thumbprints
  5. Partial Chains: If no issuer is found, the chain is marked incomplete but processing continues
  6. Self-Signed Detection: Chain building stops when a self-signed certificate is encountered

Certificate List Configuration

This section describes how certificate lists and stores are configured and populated in the OPC UA .NET Standard Stack.

Configuration Sources

Certificate lists are populated from two primary sources:

  1. Configuration File (XML): The ApplicationConfiguration file defines certificate store locations and optional explicit certificate lists
  2. Runtime API: Applications can programmatically add certificates to trust lists using the SecurityConfiguration API

Configuration File Structure

The SecurityConfiguration section in the application configuration file (*.Config.xml) defines certificate stores:

<SecurityConfiguration>
  <!-- Application's own certificates -->
  <ApplicationCertificates>
    <CertificateIdentifier>
      <StoreType>Directory</StoreType>
      <StorePath>%LocalApplicationData%/OPC Foundation/pki/own</StorePath>
      <SubjectName>CN=MyApplication, O=MyOrganization</SubjectName>
      <CertificateTypeString>RsaSha256</CertificateTypeString>
    </CertificateIdentifier>
  </ApplicationCertificates>

  <!-- Issuer certificates (Certificate Authorities) -->
  <TrustedIssuerCertificates>
    <StoreType>Directory</StoreType>
    <StorePath>%LocalApplicationData%/OPC Foundation/pki/issuer</StorePath>
    <!-- Optional: Explicit certificate list -->
    <TrustedCertificates>
      <CertificateIdentifier>
        <Thumbprint>1234567890ABCDEF...</Thumbprint>
      </CertificateIdentifier>
    </TrustedCertificates>
  </TrustedIssuerCertificates>

  <!-- Trusted peer certificates (applications) -->
  <TrustedPeerCertificates>
    <StoreType>Directory</StoreType>
    <StorePath>%LocalApplicationData%/OPC Foundation/pki/trusted</StorePath>
    <!-- Optional: Explicit certificate list -->
    <TrustedCertificates>
      <CertificateIdentifier>
        <Thumbprint>FEDCBA0987654321...</Thumbprint>
      </CertificateIdentifier>
    </TrustedCertificates>
  </TrustedPeerCertificates>

  <!-- Rejected certificates -->
  <RejectedCertificateStore>
    <StoreType>Directory</StoreType>
    <StorePath>%LocalApplicationData%/OPC Foundation/pki/rejected</StorePath>
  </RejectedCertificateStore>
</SecurityConfiguration>

Certificate Store Types

Three store types are supported out of the box. Custom store types can be registered using the ICertificateStoreProvider interface (see CertificateManager.md).

  1. Directory: File system-based certificate store

    • Certificates stored as .der or .crt files in certs/ subdirectory
    • Private keys stored as .pfx or .pem files in private/ subdirectory
    • CRLs stored in crl/ subdirectory
    • Example: %LocalApplicationData%/OPC Foundation/pki/trusted
  2. X509Store: Windows certificate store (on Windows platforms)

    • Uses Windows Certificate Store API
    • Example: CurrentUser\My or LocalMachine\Root
    • Supports CRL operations on Windows only
  3. InMemory: In-memory certificate store for testing

    • Prefix: InMemory:
    • No persistence — certificates are lost when the store is disposed

Certificate List Population

Both the new CertificateManager and the legacy CertificateValidator (via the CertificateValidatorAdapter bridge) source their trust lists from the same SecurityConfiguration defined in ApplicationConfiguration.

1. Initialization via ApplicationConfiguration
// Load configuration from file
ApplicationConfiguration config = await ApplicationConfiguration
    .Load(new FileInfo("MyApp.Config.xml"), ApplicationType.Client, null)
    .ConfigureAwait(false);

// Recommended: build a CertificateManager from the SecurityConfiguration.
// CertificateManagerFactory automatically registers the well-known trust
// lists (Peers, Users, Https, Rejected) from the configuration.
using CertificateManager manager = CertificateManagerFactory.Create(
    config.SecurityConfiguration,
    telemetry);
await manager
    .LoadApplicationCertificatesAsync(config.SecurityConfiguration, config.ApplicationUri)
    .ConfigureAwait(false);

// `ApplicationInstance` and `ServerBase` automatically construct and own
// the `CertificateManager` during `Start*Async`. Access it via
// `applicationInstance.CertificateManager` or `Server.CertificateManager`.
2. Internal Update Process

When UpdateAsync() is called, the validator performs these steps:

// From SecurityConfiguration
trustedStore = config.SecurityConfiguration.TrustedPeerCertificates
issuerStore = config.SecurityConfiguration.TrustedIssuerCertificates

// Populate internal structures
m_trustedCertificateStore = trustedStore.StorePath
m_trustedCertificateList = trustedStore.TrustedCertificates (if specified)
m_issuerCertificateStore = issuerStore.StorePath
m_issuerCertificateList = issuerStore.TrustedCertificates (if specified)
m_applicationCertificates = config.SecurityConfiguration.ApplicationCertificates
3. Certificate Search Behavior

During validation, certificates are searched in the following order:

For Trusted Certificates:

  1. Search m_trustedCertificateList (explicit list) - if populated
  2. Search m_trustedCertificateStore (file system or X509Store)
  3. Search m_applicationCertificates (application's own certificates)

For Issuer Certificates:

  1. Search m_issuerCertificateList (explicit list) - if populated
  2. Search m_issuerCertificateStore (file system or X509Store)

Runtime Certificate Management

Applications can dynamically add certificates to trust lists:

// Add a trusted peer certificate programmatically
byte[] certificateData = File.ReadAllBytes("peer-cert.der");
config.SecurityConfiguration.AddTrustedPeer(certificateData);

// Or use the CertificateManager transactional update API:
await using ITrustListTransaction tx = await manager
    .BeginUpdateAsync(TrustListIdentifier.Peers)
    .ConfigureAwait(false);
using Certificate trusted = Certificate.FromRawData(certificateData);
await tx.AddTrustedCertificateAsync(trusted).ConfigureAwait(false);
await tx.CommitAsync().ConfigureAwait(false);

Certificate Store Management

Certificates in file system stores are managed as follows:

  1. Adding Certificates: Copy certificate files to the certs/ subdirectory of the store path

    • Format: [Thumbprint].der or [Thumbprint].crt
    • Example: %LocalApplicationData%/OPC Foundation/pki/trusted/certs/1234567890ABCDEF.der
  2. Adding CRLs: Copy CRL files to the crl/ subdirectory

    • Format: [IssuerThumbprint].crl
    • Example: %LocalApplicationData%/OPC Foundation/pki/issuer/crl/FEDCBA0987654321.crl
  3. Rejected Certificates: Automatically added by the validator when validation fails

    • Stored in the rejected certificate store
    • Can be manually moved to trusted store to establish trust

Dual-Mode Operation

The validator supports both explicit lists and certificate stores:

  • Explicit List Only: Specify certificates in <TrustedCertificates> without a store path
  • Store Only: Specify store path without explicit certificates (most common)
  • Combined Mode: Use both explicit list and store for maximum flexibility
    • Explicit list is searched first for performance
    • Store is searched if not found in list

Configuration Best Practices

  1. Store Path: Use environment variables for platform independence:

    • %LocalApplicationData% - Per-user application data
    • %CommonApplicationData% - Machine-wide application data
    • Relative paths - Relative to application directory
  2. Explicit Lists: Use for:

    • Small, fixed set of trusted certificates
    • Performance optimization (faster than store enumeration)
    • Pre-deployment certificate distribution
  3. Certificate Stores: Use for:

    • Dynamic trust management
    • Administrator-managed certificate stores
    • Integration with OS certificate infrastructure
  4. Separation: Keep different certificate types in separate stores:

    • Application certificates: pki/own
    • Trusted peers: pki/trusted
    • Trusted CAs: pki/issuer
    • Rejected: pki/rejected

Configuration Settings

The certificate validation behavior is controlled by several configuration settings in the SecurityConfiguration class:

AutoAcceptUntrustedCertificates

  • Type: bool
  • Default: false
  • Description: When true, automatically accepts certificates that have the BadCertificateUntrusted status. This is useful for development environments but should not be used in production.
  • Example:
configuration.SecurityConfiguration.AutoAcceptUntrustedCertificates = true;

RejectSHA1SignedCertificates

  • Type: bool
  • Default: true (when default hash size >= 256)
  • Description: When true, rejects certificates signed with SHA-1 algorithms as they are considered cryptographically weak.
  • Example:
configuration.SecurityConfiguration.RejectSHA1SignedCertificates = true;

RejectUnknownRevocationStatus

  • Type: bool
  • Default: false
  • Description: When true, rejects certificates when the revocation status cannot be determined (e.g., CRL is not available).
  • Example:
configuration.SecurityConfiguration.RejectUnknownRevocationStatus = true;

MinimumCertificateKeySize

  • Type: ushort
  • Default: 2048 (CertificateFactory.DefaultKeySize)
  • Description: The minimum RSA key size in bits that will be accepted. Common values are 2048, 3072, or 4096.
  • Example:
configuration.SecurityConfiguration.MinimumCertificateKeySize = 2048;

UseValidatedCertificates

  • Type: bool
  • Default: false
  • Description: When true, skips validation for certificates that have already been successfully validated in the current session. This improves performance by caching validation results.
  • Example:
configuration.SecurityConfiguration.UseValidatedCertificates = true;

MaxRejectedCertificates

  • Type: int
  • Default: 5
  • Description: Limits the number of rejected certificates kept in history. A value of 0 means all rejected certificates are kept. A negative value means no history is kept.
  • Example:
configuration.SecurityConfiguration.MaxRejectedCertificates = 10;

Suppressible Validation Errors

The following validation errors can be suppressed by handling the CertificateValidation event and setting e.Accept = true:

  • BadCertificateUntrusted: The certificate is not trusted (not in the trusted store or chain).
  • BadCertificateHostNameInvalid: The domain name in the endpoint URL does not match any domain in the certificate.
  • BadCertificateIssuerRevocationUnknown: The revocation status of the issuer cannot be determined.
  • BadCertificateChainIncomplete: The certificate chain is incomplete (missing issuer certificates).
  • BadCertificateIssuerTimeInvalid: The issuer certificate has expired or is not yet valid.
  • BadCertificateIssuerUseNotAllowed: An issuer/CA certificate in the chain is not valid for use as a CA — for example it does not assert the keyCertSign and cRLSign KeyUsage bits required for a CA (see CA (issuer) KeyUsage validation).
  • BadCertificateRevocationUnknown: The revocation status of the certificate cannot be determined.
  • BadCertificateTimeInvalid: The certificate has expired or is not yet valid.
  • BadCertificatePolicyCheckFailed: The certificate does not meet policy requirements (e.g., key size, signature algorithm).
  • BadCertificateUseNotAllowed: The certificate is not valid for the intended use (missing key usage flags).

All other validation errors are non-suppressible and will always cause the validation to fail.

CA (issuer) KeyUsage validation

OPC UA requires every CA (issuer) certificate in a chain to carry a KeyUsage extension that asserts both keyCertSign and cRLSign (OPC 10000-6 §6.2.4, Table 52 — Issuer Certificate). During chain validation the stack verifies this for each issuer certificate and reports the suppressible BadCertificateIssuerUseNotAllowed error (OPC 10000-4 §6.1.3, Table 100 — "Certificate Usage") when a CA certificate is missing these bits, including the case where the CA has no KeyUsage extension at all. This matches the behaviour of strict third-party OPC UA stacks, which reject such CA certificates (typically with BadCertificateInvalid). CA certificates created by this stack's CertificateBuilder always include the required bits; to interoperate with a legacy CA that does not, suppress the error as described above.

Inspecting Certificate Validation Results

The new ICertificateValidatorEx (composed in ICertificateManager) returns a structured CertificateValidationResult describing the outcome of each validation. Callers can examine the result to decide whether to accept errors:

CertificateValidationResult result = await manager
    .ValidateAsync(certificate, TrustListIdentifier.Peers)
    .ConfigureAwait(false);

if (!result.IsValid)
{
    Console.WriteLine($"Validation failed: {result.StatusCode}");
    foreach (ServiceResult error in result.Errors)
    {
        Console.WriteLine($"  • {error}");
    }

    // Accept specific suppressible errors (e.g. untrusted certificates
    // in development) by inspecting the StatusCode.
    bool autoAccept = false;
    if (result.IsSuppressible &&
        result.StatusCode == StatusCodes.BadCertificateUntrusted &&
        autoAccept)
    {
        // Application-specific accept logic.
    }
    else
    {
        throw new ServiceResultException(result.StatusCode);
    }
}

Per-call validation behavior (e.g. auto-accepting untrusted certificates, or relaxing revocation checks) can also be overridden via CertificateValidationOptions:

var options = new CertificateValidationOptions
{
    AutoAcceptUntrustedCertificates = true,
    RejectUnknownRevocationStatus = false
};
CertificateValidationResult result = await manager
    .ValidateAsync(certificate, TrustListIdentifier.Peers, options)
    .ConfigureAwait(false);

Subscribe to manager.CertificateChanges (an IObservable<CertificateChangeEvent>) for lifecycle notifications such as ApplicationCertificateUpdated, TrustListUpdated, CrlUpdated, CertificateRejected, and CertificateExpiring. See CertificateManager.md for the full reference.

Legacy callback (deprecated): the CertificateValidator.CertificateValidation event with mutable e.Accept = true continues to work for existing applications via the backward‑compat CertificateValidator class. New code should prefer the structured result above.

Configuring a Custom Certificate Validator

To use a custom certificate validator, implement the new ICertificateValidatorEx interface (or wrap your implementation in a CertificateValidatorAdapter to expose the legacy ICertificateValidator surface):

public sealed class CustomCertificateValidator : ICertificateValidatorEx
{
    public Task<CertificateValidationResult> ValidateAsync(
        Certificate certificate,
        TrustListIdentifier? trustList = null,
        CancellationToken ct = default)
    {
        return ValidateAsync(
            new CertificateCollection(new[] { certificate }),
            trustList,
            options: null,
            ct);
    }

    public Task<CertificateValidationResult> ValidateAsync(
        CertificateCollection chain,
        TrustListIdentifier? trustList = null,
        CertificateValidationOptions? options = null,
        CancellationToken ct = default)
    {
        Certificate certificate = chain[0];

        if (!MeetsCustomRequirements(certificate))
        {
            return Task.FromResult(new CertificateValidationResult(
                isValid: false,
                statusCode: StatusCodes.BadCertificateInvalid,
                errors: new[]
                {
                    new ServiceResult(
                        StatusCodes.BadCertificateInvalid,
                        "Certificate does not meet custom requirements.")
                },
                isSuppressible: false));
        }

        return Task.FromResult(CertificateValidationResult.Success);
    }

    private static bool MeetsCustomRequirements(Certificate certificate)
    {
        // Implement your custom validation logic
        return true;
    }
}

// Bridge a custom ICertificateValidatorEx to legacy ICertificateValidator:
ICertificateValidator legacyApi = new CertificateValidatorAdapter(
    new CustomCertificateValidator());

Note

: Replacing ApplicationConfiguration.CertificateValidator with a custom implementation is still supported for backward compatibility but is discouraged in new code. Prefer providing a custom ICertificateValidatorEx (or ICertificateManager) and consuming it directly in your transport / channel pipeline.

Best Practices

  1. Production vs Development: Never use AutoAcceptUntrustedCertificates = true in production environments.

  2. Certificate Store Management: Regularly review rejected certificates in the rejected store and move trusted certificates to the appropriate trust store.

  3. Revocation Checking: Enable RejectUnknownRevocationStatus for high-security environments where CRL checking is critical.

  4. Minimum Key Size: Use at least 2048 bits for RSA keys. Consider 3072 or 4096 bits for long-term security.

  5. SHA-1 Deprecation: Keep RejectSHA1SignedCertificates = true to ensure only certificates with strong signature algorithms are accepted.

  6. Validation Callback: Always log certificate validation events for security auditing purposes.

  7. Custom Validators: When implementing a custom validator, ensure it complies with OPC UA security requirements and thoroughly test edge cases.