Files
UA-.NETStandard/tests/Opc.Ua.Server.Tests/Configuration/ConfigurationNodeManagerPushValidationTests.cs
Cristi Pogacean 2cdf0de97f Fix scoped certificate-store resolution in GDS push operations (#4726)
# Description

Fix the remaining GDS push certificate paths that bypass instance-scoped
certificate stores, while preserving OPC UA 1.05.07 trust and
transaction requirements.

- Use the configured resolver/registry for previous-key loading, slot
resolution, application-store commit/rollback, issuer
import/compensation, and rejected-store opening.
- Validate application-certificate uploads through the configured
manager and the group's registered trust scope, instead of a temporary
manager that cannot see custom providers.
- Isolate per-call `AcceptError` validation cores from normal cached
trust decisions. Part 12 installation-time acceptance must neither reuse
nor populate the connection-validation cache.
- Compensate the application-certificate swap if opening the issuer
store fails, not only if importing an issuer fails.
- Add scoped-provider, cross-group trust, cache-isolation, and
compensation regressions. Reuse the portable PEM writer and short
temporary PKI paths so the tests work on .NET Framework too.
- Use source-generated failure logging without private-key bytes, and
document custom-store/pending-key requirements.

The branch starts at master `7f14fb864fc93532f3dbab1e1b5e2ba9b5f605c5`.
It excludes the unrelated LocalizedText commits, the local handoff, PLC
changes, logs, keys, certificates, and build outputs. No public API,
dependency, package version, global provider registration, or analyzer
configuration is changed.

### Specification review

The online Part 12 reference identifies version **1.05.07**. This is a
focused review and regression validation of the changed behavior, **not
a claim of complete stack conformance or CTT certification**.

| Requirement | Review / evidence |
| --- | --- |
| [Part 12,
7.10.5](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.5):
validate application certificates using the associated TrustList; ignore
the supplied issuer list | Existing issuer-argument regressions
retained. New flat-layout scoped-provider cases accept a CA installed in
the selected trusted/issuer store and reject one installed only in
another scope. |
| [Part 4,
6.1.3](https://reference.opcfoundation.org/specs/OPC-10000-4/6.1.3) and
Part 12, 7.10.5: suppressible installation errors must not bypass normal
peer trust or non-suppressible failures | Missing-issuer,
signature/revocation, explicit-trust, and validation-core tests; new
two-way cache-isolation cases with `UseValidatedCertificates = true`;
normal peer validation remains untrusted after issuer-only push
acceptance. |
| Part 12, 7.10.5: matching private key, encrypted channel and
SecurityAdmin | Existing access/key/type tests retained; scoped
own-store upload cases cover no uploaded key and PEM. PFX and
regenerated-key cases remain in the selected suite. |
| [Part 12,
7.10.2](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.2),
[7.10.9](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.9),
and
[7.10.11](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.11):
staging, ApplyChanges, cancellation, ownership and compensation |
Push/transaction/rollback suites; new custom-provider compensation cases
cover both issuer-import and store-open failure, retaining the old
application certificate and pre-existing issuers. |
| [Part 12,
7.10.7](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.7):
do not delete endpoint-referenced certificates | Existing ApplyChanges
endpoint-reference and delete/create replacement tests retained. |
| [Part 12,
7.10.10](https://reference.opcfoundation.org/specs/OPC-10000-12/7.10.10):
CSR key handling and persistence | Existing-key scoped CSR test, SDK
pending-key/cancel cases, and supporting PLC encrypted round
trips/restarts. Arbitrary custom backends still require a durable
pending-key implementation; the separate PLC implementation is not
included here. |

### Validation

Windows, .NET SDK 10.0.401, Release, NUnit/VSTest. Build analyzers
remained enforced: **no `WarningsNotAsErrors`, suppressions, or analyzer
downgrades**. `NuGetAudit=false` was needed because the local nuget.org
TLS connection failed; no repository audit setting was changed.

| Selection | Result |
| --- | --- |
| SDK GDS push/trust/transaction/rollback, net10.0 | **192 passed, 0
failed** |
| SDK certificate-manager/provider/explicit-trust/validation-core,
net10.0 | **180 passed, 0 failed** |
| SDK certificate-manager/provider/explicit-trust/validation-core, net48
| **180 passed, 0 failed** |
| SDK GDS push/trust/transaction/rollback, net48 | **192 passed, 0
failed** |
| Final portable-PEM/compensation tests, net10.0 | **5 passed, 0
failed** |
| Supporting PLC GDS/pending-key/compatibility selection, net10.0 | **29
passed, 0 failed** against this SDK source |

The PLC tests exercise encrypted UA calls, signed PEM/CSR updates,
persistence, fresh connections and restarts across Directory,
FlatDirectory and KubernetesSecret providers. The Kubernetes API is an
in-memory double, **not a real Kubernetes deployment**. PLC tests and
its durable pending-key implementation are local companion-repository
changes, not SDK PR contents or independently reproducible CI evidence
from this branch alone.

Commands below use the same isolated artifact locations as the
validation run, expressed relative to the user profile:

```powershell
$files = Join-Path $env:USERPROFILE '.copilot\session-state\003a7438-0312-4c10-beed-cd95c0fe400e\files'
$sdk = Join-Path $files 'sdk-build'
$net48 = Join-Path $files 'solution-net48'
$push = 'FullyQualifiedName~ConfigurationNodeManagerPushTests|FullyQualifiedName~ConfigurationNodeManagerPushValidationTests|FullyQualifiedName~PushComplianceRegressionTests|FullyQualifiedName~PushRollbackCancellationRegressionTests|FullyQualifiedName~PushConfigurationTransactionCoordinatorTests'
$manager = 'FullyQualifiedName~CertificateManagerTests|FullyQualifiedName~CertificateManagerExplicitTrustTests|FullyQualifiedName~CertificateManagerProviderResolutionRegressionTests|FullyQualifiedName~CertificateValidationCoreTests'

# Passed: 192/192.
dotnet test .\tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -f net10.0 -m:1 --no-restore -p:GeneratePackageOnBuild=false -p:UseArtifactsOutput=true "-p:ArtifactsPath=$sdk" -p:NuGetAudit=false --filter $push

# Passed: 180/180.
dotnet test .\tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -c Release -f net10.0 --no-build --no-restore -p:UseArtifactsOutput=true "-p:ArtifactsPath=$sdk" --filter $manager

# Passed: 180/180.
dotnet test .\tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -c Release -f net48 --no-build --no-restore -p:CustomTestTarget=net48 -p:UseArtifactsOutput=true "-p:ArtifactsPath=$net48" --filter $manager

# Passed: 192/192 after the portable PEM writer and shorter temporary PKI paths.
dotnet test .\tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -f net48 -m:1 --no-restore -p:CustomTestTarget=net48 -p:GeneratePackageOnBuild=false -p:UseArtifactsOutput=true "-p:ArtifactsPath=$net48" -p:NuGetAudit=false --filter $push

# Passed: 5/5 after final test portability changes.
dotnet test .\tests\Opc.Ua.Server.Tests\Opc.Ua.Server.Tests.csproj -c Release -f net10.0 -m:1 --no-restore -p:GeneratePackageOnBuild=false -p:UseArtifactsOutput=true "-p:ArtifactsPath=$sdk" -p:NuGetAudit=false --filter 'FullyQualifiedName~CertificatePushUsesScopedOwnStoreProviderAsync|FullyQualifiedName~ApplyCertificateSlotChangeSelfCompensatesWhenIssuerImportFailsAfterAppCertSwapAsync'

# Passed: 29/29; the companion project and this SDK had been built together first.
dotnet test C:\src\iot-edge-opc-plc\tests\opc-plc-tests.csproj -c Release --no-build --no-restore -p:UseLocalOpcUaStack=true -p:OpcUaSourceRoot=C:\src\UA-.NETStandard -p:UseArtifactsOutput=true "-p:ArtifactsPath=$sdk" --filter 'FullyQualifiedName~GdsPush|FullyQualifiedName~PlcPendingCertificateKeyStoreTests|FullyQualifiedName~PreviewCompatibilityTests'
```

The `--no-restore` / `--no-build` invocations require prior
restoration/build. The initially empty artifact-directory `--no-restore`
invocation produced no test execution and is **not** counted. Initial
compile/test portability failures were fixed before the final results
above.

Full-solution attempts did **not** pass:

```powershell
# Failed/incomplete: AMB server startup timed out in two tests; stopped after failures.
dotnet test .\UA.slnx -c Release -f net48 -m:1 -p:CustomTestTarget=net48 -p:GeneratePackageOnBuild=false -p:UseArtifactsOutput=true "-p:ArtifactsPath=$net48" -p:NuGetAudit=false

# Failed: NU1301 restoring OpenUsd-related projects due to nuget.org TLS handshake failure.
dotnet test .\UA.slnx -c Release -f net10.0 -m:1 -p:CustomTestTarget=net10.0 -p:GeneratePackageOnBuild=false -p:UseArtifactsOutput=true "-p:ArtifactsPath=$(Join-Path $files 'solution-net10')" -p:NuGetAudit=false
```

A retry through an already configured local package source progressed
further, but the OneFuzz nested publish did not inherit the audit
override and caused **210 setup failures (NU1900)**; the broad run was
stopped. These runs are not a clean full-suite result. AMB startup
failures have not been baseline-qualified and are not claimed to be
fixed by this PR.

`git diff --check` passed. A changed-file whitespace check reported
seven pre-existing diagnostics at lines 371-424 of
`CertificateManagerTests.cs`, outside this change (new tests start at
line 563); no unrelated formatting sweep was made.

**Remaining gates:** successful full solution on net48/net10.0,
quantitative coverage, Linux, required CI/CodeQL, and applicable
CTT/interoperability qualification. Keep this PR draft until the
required gates are resolved; the tests above do not establish 100% OPC
UA compliance.

## Related Issues

_Reference all GitHub issues this PR addresses. If there is no issue
yet, open one and link it here._

_If this is a relatively large or complex change, a design must have
been discussed in the related tracking issue and signed off (which
becomes the Architectural Decision Record (ADR))._

- Fixes #4725.
- Follow-up to #4546, #4530 and #4531 for remaining certificate-push
consumers.

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] 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.

Tests were added and pass as recorded, but coverage was not measured.
CLA status is not asserted on behalf of the contributor.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-10-09 21:41:58 +02:00

704 lines
31 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Security.Certificates;
using Opc.Ua.Tests;
namespace Opc.Ua.Server.Tests
{
/// <summary>
/// Direct, fast unit tests of the small argument-validation helpers
/// <see cref="ConfigurationNodeManager"/> extracts for
/// <c>CreateSelfSignedCertificate</c> (OPC 10000-12 §7.10.6),
/// <c>GetCertificates</c> (§7.10.8) and <c>UpdateCertificate</c>
/// (§7.10.5), independent of the address-space / method-call plumbing
/// exercised by <see cref="ConfigurationNodeManagerPushTests"/>.
/// </summary>
[TestFixture]
[Category("ConfigurationNodeManager")]
[Parallelizable]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class ConfigurationNodeManagerPushValidationTests
{
private static readonly ITelemetryContext s_telemetry = NUnitTelemetryContext.Create();
private string m_basePath;
[SetUp]
public void SetUp()
{
m_basePath = Path.Combine(
TestContext.CurrentContext.WorkDirectory,
"cnmv",
Guid.NewGuid().ToString("N")[..8]);
}
[TearDown]
public void TearDown()
{
if (Directory.Exists(m_basePath))
{
Directory.Delete(m_basePath, true);
}
}
[Test]
public void CreateDefaultApplicationCertificateSubjectNameUsesApplicationName()
{
string subjectName = ConfigurationNodeManager
.CreateDefaultApplicationCertificateSubjectName("MyTestApplication");
Assert.That(subjectName, Does.Contain("CN=MyTestApplication"));
Assert.That(subjectName, Does.Contain("O=OPC Foundation"));
}
[Test]
public void CreateDefaultApplicationCertificateSubjectNameFallsBackWhenApplicationNameIsEmpty()
{
Assert.That(
() => ConfigurationNodeManager.CreateDefaultApplicationCertificateSubjectName(null),
Throws.Nothing);
string subjectName = ConfigurationNodeManager
.CreateDefaultApplicationCertificateSubjectName(string.Empty);
Assert.That(subjectName, Does.Contain("CN="));
}
[Test]
public void CreateDefaultApplicationCertificateSubjectNameSanitizesSeparatorCharacters()
{
string subjectName = ConfigurationNodeManager
.CreateDefaultApplicationCertificateSubjectName("My/App,Name;Here");
// The sanitized value must still parse as exactly two RDN
// fields (CN and O); embedded field separators would
// otherwise be misinterpreted as additional RDN boundaries.
List<string> fields = X509Utils.ParseDistinguishedName(subjectName);
Assert.That(fields, Has.Count.EqualTo(2));
Assert.That(fields[0], Does.StartWith("CN="));
Assert.That(fields[0], Does.Not.Contain("/"));
Assert.That(fields[0], Does.Not.Contain(";"));
}
[Test]
public void SubjectCommonNameMatchesDomainReturnsTrueForExactMatch()
{
bool matches = ConfigurationNodeManager.SubjectCommonNameMatchesDomain(
"CN=my.host.name, O=OPC Foundation",
["other.host", "my.host.name"]);
Assert.That(matches, Is.True);
}
[Test]
public void SubjectCommonNameMatchesDomainIsCaseInsensitive()
{
bool matches = ConfigurationNodeManager.SubjectCommonNameMatchesDomain(
"CN=MY.HOST.NAME",
["my.host.name"]);
Assert.That(matches, Is.True);
}
[Test]
public void SubjectCommonNameMatchesDomainReturnsFalseWhenNoMatch()
{
bool matches = ConfigurationNodeManager.SubjectCommonNameMatchesDomain(
"CN=unrelated.host",
["my.host.name", "127.0.0.1"]);
Assert.That(matches, Is.False);
}
[Test]
public void SubjectCommonNameMatchesDomainReturnsFalseWhenSubjectHasNoCommonName()
{
bool matches = ConfigurationNodeManager.SubjectCommonNameMatchesDomain(
"O=OPC Foundation",
["my.host.name"]);
Assert.That(matches, Is.False);
}
[TestCase((ushort)0, true)]
[TestCase((ushort)1024, true)]
[TestCase((ushort)2048, true)]
[TestCase((ushort)3072, true)]
[TestCase((ushort)4096, true)]
[TestCase((ushort)1536, false)]
public void ValidateKeySizeForGenericApplicationCertificateType(ushort keySize, bool supported)
{
Action action = () => ConfigurationNodeManager.ValidateKeySizeForCertificateType(
ObjectTypeIds.ApplicationCertificateType,
isRsaCertificateType: true,
keySize);
AssertSupportedOrBadOutOfRange(action, supported);
}
[TestCase((ushort)1024, true)]
[TestCase((ushort)2048, true)]
[TestCase((ushort)3072, false)]
[TestCase((ushort)4096, false)]
public void ValidateKeySizeForRsaMinApplicationCertificateType(ushort keySize, bool supported)
{
Action action = () => ConfigurationNodeManager.ValidateKeySizeForCertificateType(
ObjectTypeIds.RsaMinApplicationCertificateType,
isRsaCertificateType: true,
keySize);
AssertSupportedOrBadOutOfRange(action, supported);
}
[TestCase((ushort)1024, false)]
[TestCase((ushort)2048, true)]
[TestCase((ushort)3072, true)]
[TestCase((ushort)4096, true)]
public void ValidateKeySizeForRsaSha256ApplicationCertificateType(ushort keySize, bool supported)
{
Action action = () => ConfigurationNodeManager.ValidateKeySizeForCertificateType(
ObjectTypeIds.RsaSha256ApplicationCertificateType,
isRsaCertificateType: true,
keySize);
AssertSupportedOrBadOutOfRange(action, supported);
}
[TestCase((ushort)256, true)]
[TestCase((ushort)384, false)]
public void ValidateKeySizeForEccNistP256ApplicationCertificateType(ushort keySize, bool supported)
{
Action action = () => ConfigurationNodeManager.ValidateKeySizeForCertificateType(
ObjectTypeIds.EccNistP256ApplicationCertificateType,
isRsaCertificateType: false,
keySize);
AssertSupportedOrBadOutOfRange(action, supported);
}
[TestCase((ushort)384, true)]
[TestCase((ushort)256, false)]
public void ValidateKeySizeForEccNistP384ApplicationCertificateType(ushort keySize, bool supported)
{
Action action = () => ConfigurationNodeManager.ValidateKeySizeForCertificateType(
ObjectTypeIds.EccNistP384ApplicationCertificateType,
isRsaCertificateType: false,
keySize);
AssertSupportedOrBadOutOfRange(action, supported);
}
private static void AssertSupportedOrBadOutOfRange(Action action, bool supported)
{
if (supported)
{
Assert.DoesNotThrow(action);
return;
}
ServiceResultException exception = Assert.Throws<ServiceResultException>(action);
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadOutOfRange));
}
[Test]
public void SelectOccupiedCertificateSlotsOmitsEmptySlotsAndPreservesOrder()
{
NodeId rsaType = ObjectTypeIds.RsaSha256ApplicationCertificateType;
NodeId eccType = ObjectTypeIds.EccNistP256ApplicationCertificateType;
NodeId httpsType = ObjectTypeIds.HttpsCertificateType;
ArrayOf<CertificateIdentifier> applicationCertificates =
[
new CertificateIdentifier { CertificateType = rsaType },
new CertificateIdentifier { CertificateType = eccType },
new CertificateIdentifier { CertificateType = httpsType }
];
using Certificate rsaCert = CertificateBuilder
.Create("CN=Occupied Slot " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate httpsCert = CertificateBuilder
.Create("CN=Occupied Slot Https " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using var emptyIssuerChain = new CertificateCollection();
using var rsaEntry = new CertificateEntry(rsaCert, emptyIssuerChain, rsaType);
using var httpsEntry = new CertificateEntry(httpsCert, emptyIssuerChain, httpsType);
(ArrayOf<NodeId> types, ArrayOf<ByteString> certificates) = ConfigurationNodeManager
.SelectOccupiedCertificateSlots(
applicationCertificates,
certificateType => certificateType == rsaType
? rsaEntry.AddRef()
: certificateType == httpsType
? httpsEntry.AddRef()
: null);
// eccType is unoccupied and must be omitted; rsaType/httpsType
// must be reported in their original configured order.
Assert.That(types.ToList(), Is.EqualTo(new[] { rsaType, httpsType }));
Assert.That(certificates.Count, Is.EqualTo(2));
using Certificate roundTrippedRsa = Certificate.FromRawData(certificates[0]);
Assert.That(roundTrippedRsa.Thumbprint, Is.EqualTo(rsaCert.Thumbprint));
}
[Test]
public void SelectOccupiedCertificateSlotsReturnsEmptyArraysForFullyEmptyGroup()
{
ArrayOf<CertificateIdentifier> applicationCertificates =
[
new CertificateIdentifier { CertificateType = ObjectTypeIds.RsaSha256ApplicationCertificateType },
new CertificateIdentifier { CertificateType = ObjectTypeIds.EccNistP256ApplicationCertificateType }
];
(ArrayOf<NodeId> types, ArrayOf<ByteString> certificates) = ConfigurationNodeManager
.SelectOccupiedCertificateSlots(applicationCertificates, _ => null);
Assert.That(types.Count, Is.Zero);
Assert.That(certificates.Count, Is.Zero);
}
[Test]
public void SelectOccupiedCertificateSlotsThrowsForNullResolver()
{
Assert.Throws<ArgumentNullException>(() =>
ConfigurationNodeManager.SelectOccupiedCertificateSlots(
ArrayOf<CertificateIdentifier>.Empty,
null!));
}
[Test]
public async Task ValidateCertificateAgainstGroupTrustListAsyncAcceptsACertificateDirectlyTrustedAsync()
{
(CertificateStoreIdentifier trustedStore, CertificateStoreIdentifier issuerStore) = CreateEmptyStores();
using Certificate selfSigned = CertificateBuilder
.Create("CN=Directly Trusted " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using (ICertificateStore store = trustedStore.OpenStore(s_telemetry))
{
await store.AddAsync(selfSigned, ct: CancellationToken.None).ConfigureAwait(false);
}
Assert.DoesNotThrowAsync(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore,
issuerStore,
"TestGroup-" + Guid.NewGuid().ToString("N")[..8],
selfSigned,
new SecurityConfiguration(),
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public async Task ValidateCertificateAgainstGroupTrustListAsyncAcceptsAnUntrustedSelfSignedCertificateAsync()
{
// OPC 10000-12 §7.10.5: "All suppressible errors shall be
// ignored." BadCertificateUntrusted is suppressible, so a
// self-signed certificate absent from every store must still
// be accepted rather than rejected.
(CertificateStoreIdentifier trustedStore, CertificateStoreIdentifier issuerStore) = CreateEmptyStores();
using Certificate untrusted = CertificateBuilder
.Create("CN=Never Trusted " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
Assert.DoesNotThrowAsync(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore,
issuerStore,
"TestGroup-" + Guid.NewGuid().ToString("N")[..8],
untrusted,
new SecurityConfiguration(),
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[TestCase(true)]
[TestCase(false)]
public async Task ValidateCertificateAgainstGroupTrustListAsyncAcceptsACaSignedCertificateWhenTheCaIsInstalledAsync(
bool caInTrustedStore)
{
// OPC 10000-12 §7.10.5: "The validation process requires that
// the TrustList associated with the CertificateGroup already
// contains the IssuerCertificates." A renewal signed by a CA
// installed in either list of the group's TrustList must be
// accepted without any caller-supplied issuer chain.
(CertificateStoreIdentifier trustedStore, CertificateStoreIdentifier issuerStore) = CreateEmptyStores();
using Certificate ca = CreateCa("CN=Installed CA ");
using Certificate issued = CreateIssued(ca);
CertificateStoreIdentifier installTo = caInTrustedStore ? trustedStore : issuerStore;
using (ICertificateStore store = installTo.OpenStore(s_telemetry))
{
await store.AddAsync(ca, ct: CancellationToken.None).ConfigureAwait(false);
}
Assert.DoesNotThrowAsync(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore,
issuerStore,
"TestGroup-" + Guid.NewGuid().ToString("N")[..8],
issued,
new SecurityConfiguration(),
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
/// <summary>
/// Push validation resolves scoped stores without accepting issuers from another group.
/// </summary>
[TestCase(true, true)]
[TestCase(false, true)]
[TestCase(true, false)]
[TestCase(false, false)]
public async Task GroupTrustValidationUsesScopedProviderAndStillRequiresIssuerAsync(
bool caInTrustedStore, bool installIssuer)
{
(CertificateStoreIdentifier trustedStore, CertificateStoreIdentifier issuerStore) = CreateEmptyStores();
const string storeType = "ScopedFlatDirectory";
trustedStore.StoreType = storeType;
issuerStore.StoreType = storeType;
var provider = new Mock<ICertificateStoreProvider>(MockBehavior.Strict);
provider.SetupGet(instance => instance.StoreTypeName).Returns(storeType);
provider.Setup(instance => instance.SupportsStorePath(It.IsAny<string>())).Returns(true);
provider.Setup(instance => instance.CreateStore(It.IsAny<ITelemetryContext>()))
.Returns((ITelemetryContext telemetry) => new DirectoryCertificateStore(true, telemetry));
using var manager = new CertificateManager(s_telemetry, [provider.Object]);
manager.MapFromSecurityConfiguration(new SecurityConfiguration
{
UseValidatedCertificates = true,
AutoAcceptUntrustedCertificates = false
});
var scope = new TrustListIdentifier("ScopedGroup");
manager.RegisterTrustList(scope, trustedStore.StorePath!, issuerStore.StorePath);
using Certificate ca = CreateCa("CN=Scoped CA ");
using Certificate issued = CreateIssued(ca);
var otherScope = new TrustListIdentifier("OtherGroup");
manager.RegisterTrustList(
otherScope, Path.Combine(m_basePath, "other-trusted"), Path.Combine(m_basePath, "other-issuer"));
TrustListIdentifier installScope = installIssuer ? scope : otherScope;
using (ICertificateStore store = (caInTrustedStore
? manager.OpenTrustedStore(installScope)
: manager.OpenIssuerStore(installScope)) ??
throw new InvalidOperationException("The scoped test store must be configured."))
{
await store.AddAsync(ca, ct: CancellationToken.None).ConfigureAwait(false);
}
if (installIssuer)
{
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore, issuerStore, scope.Name, issued, new SecurityConfiguration(),
s_telemetry, CancellationToken.None, manager).ConfigureAwait(false);
provider.Verify(instance => instance.CreateStore(It.IsAny<ITelemetryContext>()), Times.AtLeastOnce);
}
else
{
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore, issuerStore, scope.Name, issued, new SecurityConfiguration(),
s_telemetry, CancellationToken.None, manager).ConfigureAwait(false));
Assert.That(ContainsStatusCode(error.Result, StatusCodes.BadCertificateChainIncomplete), Is.True);
}
CertificateValidationResult peerValidation = await manager.ValidateAsync(issued, scope)
.ConfigureAwait(false);
if (!installIssuer || !caInTrustedStore)
{
Assert.That(peerValidation.IsValid, Is.False, "Push acceptance must not grant peer trust.");
}
}
[Test]
public void ValidateCertificateAgainstGroupTrustListAsyncRejectsACaSignedCertificateWhoseCaIsNotInstalled()
{
// OPC 10000-4 Table 100: "Build Certificate Chain ... An error
// during the chain creation may not be suppressed." The issuer
// must already be in the group's TrustList (OPC 10000-12
// §7.10.5), so accepting every suppressible error must still
// reject a certificate whose issuer the server does not know.
(CertificateStoreIdentifier trustedStore, CertificateStoreIdentifier issuerStore) = CreateEmptyStores();
using Certificate ca = CreateCa("CN=Uninstalled CA ");
using Certificate issued = CreateIssued(ca);
ServiceResultException sre = Assert.ThrowsAsync<ServiceResultException>(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
trustedStore,
issuerStore,
"TestGroup-" + Guid.NewGuid().ToString("N")[..8],
issued,
new SecurityConfiguration(),
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
Assert.That(ContainsStatusCode(sre.Result, StatusCodes.BadCertificateChainIncomplete), Is.True,
sre.Result.ToLongString());
}
[Test]
public void ValidateCertificateAgainstGroupTrustListAsyncThrowsForNullTrustedStore()
{
using Certificate certificate = CertificateBuilder
.Create("CN=Guard Clause Test")
.SetRSAKeySize(2048)
.CreateForRSA();
Assert.ThrowsAsync<ArgumentNullException>(async () =>
await ConfigurationNodeManager.ValidateCertificateAgainstGroupTrustListAsync(
null!,
null,
"TestGroup",
certificate,
new SecurityConfiguration(),
s_telemetry,
CancellationToken.None)
.ConfigureAwait(false));
}
[Test]
public void IsCertificateReferencedByEndpointMatchesTheEndpointServerCertificateThumbprint()
{
using Certificate referenced = CertificateBuilder
.Create("CN=Referenced " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate other = CertificateBuilder
.Create("CN=Other " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
ArrayOf<EndpointDescription> endpoints = ArrayOf.Wrapped(new[]
{
new EndpointDescription
{
SecurityMode = MessageSecurityMode.None
},
new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
ServerCertificate = referenced.RawData.ToByteString()
}
});
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
referenced.Thumbprint, endpoints, null, s_telemetry),
Is.True,
"a certificate presented by an endpoint must be reported as referenced");
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
other.Thumbprint, endpoints, null, s_telemetry),
Is.False,
"a certificate not presented by any endpoint must not be reported as referenced");
}
[Test]
public void IsCertificateReferencedByEndpointReturnsFalseForAnEmptyEndpointSet()
{
using Certificate certificate = CertificateBuilder
.Create("CN=Unreferenced " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
certificate.Thumbprint, default, null, s_telemetry),
Is.False);
}
[Test]
public void IsCertificateReferencedByEndpointResolvesTheCurrentCertificateFromTheRegistry()
{
// Regression for the endpoint-reference check using startup-only
// EndpointDescription.ServerCertificate data (OPC 10000-12 §7.10.7):
// after a rotation the endpoint's cached ServerCertificate blob is
// the previous certificate (A) while the live registry maps the
// endpoint's SecurityPolicyUri to the rotated certificate (B). The
// check must protect B (the certificate presented right now) and
// must NOT protect A (no longer presented), resolving the
// assignment from the registry rather than the stale blob.
using Certificate previous = CertificateBuilder
.Create("CN=Rotated Out " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
using Certificate rotated = CertificateBuilder
.Create("CN=Currently Presented " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
// The endpoint still advertises the stale previous-certificate blob.
ArrayOf<EndpointDescription> endpoints = ArrayOf.Wrapped(new[]
{
new EndpointDescription
{
SecurityMode = MessageSecurityMode.SignAndEncrypt,
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
ServerCertificate = previous.RawData.ToByteString()
}
});
using var emptyIssuerChain = new CertificateCollection();
using var liveEntry = new CertificateEntry(
rotated, emptyIssuerChain, ObjectTypeIds.RsaSha256ApplicationCertificateType);
var registry = new Mock<ICertificateRegistry>();
registry
.Setup(r => r.AcquireApplicationCertificateBySecurityPolicy(It.IsAny<string>()))
.Returns(() => liveEntry.AddRef());
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
rotated.Thumbprint, endpoints, registry.Object, s_telemetry),
Is.True,
"the certificate currently presented (resolved from the registry) must be protected");
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
previous.Thumbprint, endpoints, registry.Object, s_telemetry),
Is.False,
"the stale endpoint blob must not keep a rotated-out certificate referenced");
}
[Test]
public void IsCertificateReferencedByEndpointIgnoresUnencryptedEndpointsWhenRegistryPresent()
{
// An endpoint that does not require encryption presents no channel
// certificate, so it must never keep a certificate referenced when
// the live registry is consulted.
using Certificate certificate = CertificateBuilder
.Create("CN=Unencrypted " + Guid.NewGuid().ToString("N")[..8])
.SetRSAKeySize(2048)
.CreateForRSA();
ArrayOf<EndpointDescription> endpoints = ArrayOf.Wrapped(new[]
{
new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
ServerCertificate = certificate.RawData.ToByteString()
}
});
using var emptyIssuerChain = new CertificateCollection();
using var liveEntry = new CertificateEntry(
certificate, emptyIssuerChain, ObjectTypeIds.RsaSha256ApplicationCertificateType);
var registry = new Mock<ICertificateRegistry>();
registry
.Setup(r => r.AcquireApplicationCertificateBySecurityPolicy(It.IsAny<string>()))
.Returns(() => liveEntry.AddRef());
Assert.That(
ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
certificate.Thumbprint, endpoints, registry.Object, s_telemetry),
Is.False);
}
[Test]
public async Task NoneEndpointProtectsItsRsaTokenCertificateInsteadOfThePrimaryEccCertificateAsync()
{
using var registry = new CertificateManager(s_telemetry);
using Certificate ecc = CertificateBuilder.Create("CN=Primary ECC")
.SetECCurve(ECCurve.NamedCurves.nistP256).CreateForECDsa();
using Certificate rsa = CertificateBuilder.Create("CN=Token RSA").CreateForRSA();
await registry.UpdateApplicationCertificateAsync(ObjectTypeIds.EccNistP256ApplicationCertificateType, ecc)
.ConfigureAwait(false);
await registry.UpdateApplicationCertificateAsync(ObjectTypeIds.RsaSha256ApplicationCertificateType, rsa)
.ConfigureAwait(false);
ArrayOf<EndpointDescription> endpoints = [new EndpointDescription
{
SecurityMode = MessageSecurityMode.None,
SecurityPolicyUri = SecurityPolicies.None,
UserIdentityTokens = [new UserTokenPolicy(UserTokenType.UserName)
{
SecurityPolicyUri = SecurityPolicies.Basic256Sha256
}]
}];
Assert.That(ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
rsa.Thumbprint, endpoints, registry, s_telemetry), Is.True);
Assert.That(ConfigurationNodeManager.IsCertificateReferencedByEndpoint(
ecc.Thumbprint, endpoints, registry, s_telemetry), Is.False);
}
private static Certificate CreateCa(string subjectPrefix)
{
return CertificateBuilder
.Create(subjectPrefix + Guid.NewGuid().ToString("N")[..8])
.SetCAConstraint(0)
.SetRSAKeySize(2048)
.CreateForRSA();
}
private static Certificate CreateIssued(Certificate ca)
{
return CertificateBuilder
.Create("CN=Issued " + Guid.NewGuid().ToString("N")[..8])
.SetIssuer(ca)
.SetRSAKeySize(2048)
.CreateForRSA();
}
private static bool ContainsStatusCode(ServiceResult result, StatusCode statusCode)
{
for (ServiceResult current = result; current != null; current = current.InnerResult!)
{
if (current.StatusCode == statusCode)
{
return true;
}
}
return false;
}
private (CertificateStoreIdentifier TrustedStore, CertificateStoreIdentifier IssuerStore) CreateEmptyStores()
{
return (
new CertificateStoreIdentifier(Path.Combine(m_basePath, "trusted")),
new CertificateStoreIdentifier(Path.Combine(m_basePath, "issuer")));
}
}
}