mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-26 04:19:53 +08:00
# Description This PR splits the GDS KeyCredential changes from #4021. ## Specification - [OPC 10000-12 v1.05.07 §8.5.5](https://reference.opcfoundation.org/specs/OPC-10000-12/v1.05.07/8.5.5) requires `StartRequest` to use an encrypted SecureChannel and a Client with the `KeyCredentialAdmin` Role, `ApplicationAdmin` Privilege, or `ApplicationSelfAdmin` Privilege. The `ApplicationUri` must uniquely identify an application known to the GDS. - [OPC 10000-12 v1.05.07 §8.5.6](https://reference.opcfoundation.org/specs/OPC-10000-12/v1.05.07/8.5.6) applies the same authorization requirements, requires `FinishRequest` to use the same SecureChannel Certificate as `StartRequest`, and defines `Bad_RequestNotComplete` for a pending request. - [OPC 10000-12 v1.05.07 §8.5.7](https://reference.opcfoundation.org/specs/OPC-10000-12/v1.05.07/8.5.7) applies the same authorization requirements to `Revoke` and defines `Bad_InvalidArgument` for an unknown credential. ## Failure The KeyCredential methods previously verified only an authenticated encrypted SecureChannel. They did not enforce the specification's administrative Role or application-scoped Privileges, persist immutable application ownership, or bind `FinishRequest` to the initiating Client certificate. A pending request returned `BadNothingToDo`, invalid ownership lookups used non-conformant status codes, and failure audit text could expose exception details. ## Fix - Publish and enforce `KeyCredentialAdmin`, `ApplicationAdmin`, and `ApplicationSelfAdmin` permissions, including application scoping. - Resolve `ApplicationUri` to exactly one registered application and persist its immutable `ApplicationId`. - Bind requests to the initiating SecureChannel certificate with a SHA-256 fingerprint and compare it in fixed time when finishing. - Atomically re-check request and credential ownership, and fail closed for stores that cannot enforce the new ownership contract. - Return `BadRequestNotComplete` for pending requests and conformant invalid-argument results for unknown request or credential identifiers. - Emit success and failure audit events while redacting KeyCredential exception details. ## Validation - Full `Opc.Ua.Gds.Tests`: net10.0 (1053 passed, 42 skipped), net48 (858 passed, 237 skipped). - Review follow-up `KeyCredentialRequestStoreTests`: net10.0 (16 passed), net48 (16 passed). - `git diff --check` passed. ## Related Issues - Split from #4021. ## Checklist _Only verified claims are checked._ - [ ] 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. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [ ] 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. - [x] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
301 lines
12 KiB
C#
301 lines
12 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 Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Gds.Server;
|
|
using Opc.Ua.Gds.Server.Database;
|
|
|
|
namespace Opc.Ua.Gds.Tests
|
|
{
|
|
[TestFixture]
|
|
[Category("KeyCredential")]
|
|
[Parallelizable]
|
|
public sealed class KeyCredentialConformanceTests
|
|
{
|
|
private const string ApplicationUri = "urn:test:keycredential-owner";
|
|
|
|
[Test]
|
|
public void ApplicationUriResolvesOneExactRegisteredApplication()
|
|
{
|
|
var applicationId = new NodeId(42);
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns(
|
|
[
|
|
new ApplicationRecordDataType
|
|
{
|
|
ApplicationId = applicationId,
|
|
ApplicationUri = ApplicationUri
|
|
}
|
|
]);
|
|
|
|
NodeId resolved = ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri);
|
|
|
|
Assert.That(resolved, Is.EqualTo(applicationId));
|
|
}
|
|
|
|
[Test]
|
|
public void UnknownApplicationUriReturnsBadNotFound()
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns([]);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadNotFound));
|
|
}
|
|
|
|
[Test]
|
|
public void NullDatabaseThrowsArgumentNullException()
|
|
{
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
null!,
|
|
ApplicationUri),
|
|
Throws.ArgumentNullException);
|
|
}
|
|
|
|
[TestCase(null)]
|
|
[TestCase("")]
|
|
[TestCase(" ")]
|
|
public void EmptyApplicationUriReturnsBadNotFound(string? applicationUri)
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
applicationUri!),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadNotFound));
|
|
}
|
|
|
|
[Test]
|
|
public void NullApplicationSearchResultReturnsBadNotFound()
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns((ApplicationRecordDataType[]?)null);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadNotFound));
|
|
}
|
|
|
|
[Test]
|
|
public void NonExactApplicationUriMatchReturnsBadNotFound()
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns(
|
|
[
|
|
new ApplicationRecordDataType
|
|
{
|
|
ApplicationId = new NodeId(42),
|
|
ApplicationUri = ApplicationUri.ToUpperInvariant()
|
|
}
|
|
]);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadNotFound));
|
|
}
|
|
|
|
[Test]
|
|
public void NullApplicationIdReturnsBadConfigurationError()
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns(
|
|
[
|
|
new ApplicationRecordDataType
|
|
{
|
|
ApplicationId = NodeId.Null,
|
|
ApplicationUri = ApplicationUri
|
|
}
|
|
]);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadConfigurationError));
|
|
}
|
|
|
|
[Test]
|
|
public void DuplicateApplicationUriReturnsBadConfigurationError()
|
|
{
|
|
var database = new Mock<IApplicationsDatabase>(MockBehavior.Strict);
|
|
database
|
|
.Setup(db => db.FindApplications(ApplicationUri))
|
|
.Returns(
|
|
[
|
|
new ApplicationRecordDataType
|
|
{
|
|
ApplicationId = new NodeId(42),
|
|
ApplicationUri = ApplicationUri
|
|
},
|
|
new ApplicationRecordDataType
|
|
{
|
|
ApplicationId = new NodeId(43),
|
|
ApplicationUri = ApplicationUri
|
|
}
|
|
]);
|
|
|
|
Assert.That(
|
|
() => ApplicationsNodeManager.ResolveKeyCredentialApplicationId(
|
|
database.Object,
|
|
ApplicationUri),
|
|
Throws.TypeOf<ServiceResultException>()
|
|
.With.Property(nameof(ServiceResultException.StatusCode))
|
|
.EqualTo(StatusCodes.BadConfigurationError));
|
|
}
|
|
|
|
[Test]
|
|
public void PendingFinishReturnsBadRequestNotComplete()
|
|
{
|
|
var finished = new FinishKeyCredentialRequestResult
|
|
{
|
|
State = KeyCredentialRequestState.New,
|
|
CredentialId = "must-not-be-returned",
|
|
CredentialSecret = ByteString.From([1, 2, 3])
|
|
};
|
|
|
|
KeyCredentialFinishRequestMethodStateResult result =
|
|
ApplicationsNodeManager.CreateKeyCredentialFinishResult(
|
|
finished,
|
|
cancelRequest: false);
|
|
|
|
Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadRequestNotComplete));
|
|
Assert.That(result.CredentialId, Is.Null.Or.Empty);
|
|
Assert.That(result.CredentialSecret.IsEmpty, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void CompletedFinishReturnsAllCredentialOutputs()
|
|
{
|
|
ArrayOf<NodeId> grantedRoles =
|
|
new NodeId[] { new(1), new(2) }.ToArrayOf();
|
|
var finished = new FinishKeyCredentialRequestResult
|
|
{
|
|
State = KeyCredentialRequestState.Completed,
|
|
CredentialId = "KC-42",
|
|
CredentialSecret = ByteString.From([1, 2, 3]),
|
|
CertificateThumbprint = "thumbprint",
|
|
SecurityPolicyUri = SecurityPolicies.Basic256Sha256,
|
|
GrantedRoles = grantedRoles
|
|
};
|
|
|
|
KeyCredentialFinishRequestMethodStateResult result =
|
|
ApplicationsNodeManager.CreateKeyCredentialFinishResult(
|
|
finished,
|
|
cancelRequest: false);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
|
|
Assert.That(result.CredentialId, Is.EqualTo("KC-42"));
|
|
Assert.That(result.CredentialSecret.ToArray(), Is.EqualTo(new byte[] { 1, 2, 3 }));
|
|
Assert.That(result.CertificateThumbprint, Is.EqualTo("thumbprint"));
|
|
Assert.That(result.SecurityPolicyUri, Is.EqualTo(SecurityPolicies.Basic256Sha256));
|
|
Assert.That(result.GrantedRoles, Is.EqualTo(grantedRoles));
|
|
});
|
|
}
|
|
|
|
[TestCase(KeyCredentialRequestState.Approved)]
|
|
[TestCase(KeyCredentialRequestState.Completed)]
|
|
public void NonPendingFinishStateReturnsGood(KeyCredentialRequestState state)
|
|
{
|
|
ServiceResult result =
|
|
ApplicationsNodeManager.GetKeyCredentialFinishServiceResult(
|
|
state,
|
|
cancelRequest: false);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void RejectedFinishReturnsBadRequestNotAllowed()
|
|
{
|
|
ServiceResult result =
|
|
ApplicationsNodeManager.GetKeyCredentialFinishServiceResult(
|
|
KeyCredentialRequestState.Rejected,
|
|
cancelRequest: false);
|
|
|
|
Assert.That(
|
|
result.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadRequestNotAllowed));
|
|
}
|
|
|
|
[Test]
|
|
public void CancelledRejectedFinishReturnsGoodWithoutCredentialOutputs()
|
|
{
|
|
var finished = new FinishKeyCredentialRequestResult
|
|
{
|
|
State = KeyCredentialRequestState.Rejected,
|
|
CredentialId = "must-not-be-returned",
|
|
CredentialSecret = ByteString.From([1, 2, 3])
|
|
};
|
|
|
|
KeyCredentialFinishRequestMethodStateResult result =
|
|
ApplicationsNodeManager.CreateKeyCredentialFinishResult(
|
|
finished,
|
|
cancelRequest: true);
|
|
|
|
Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True);
|
|
Assert.That(result.CredentialId, Is.Null.Or.Empty);
|
|
Assert.That(result.CredentialSecret.IsEmpty, Is.True);
|
|
}
|
|
}
|
|
}
|