From 80c2f081b2f663460015c6f1463584d38209ea23 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 13:20:43 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Enforce=20KeyCredential=20authorization=20a?= =?UTF-8?q?nd=20ownership=20(OPC=2010000-12=20=C2=A7=C2=A78.5.5-8.5.7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApplicationsNodeManager.cs | 215 ++++++-- .../Diagnostics/AuditEvents.cs | 24 +- .../IKeyCredentialRequestStore.cs | 476 +++++++++++++++++- .../AuthorizationHelper.cs | 75 ++- .../RoleBasedUserManagement/GdsRole.cs | 7 + tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs | 59 ++- .../AuthorizationHelperTests.cs | 162 ++++++ tests/Opc.Ua.Gds.Tests/GdsTestFixture.cs | 1 + .../KeyCredentialConformanceTests.cs | 140 ++++++ .../KeyCredentialRequestStoreTests.cs | 240 +++++++++ 10 files changed, 1321 insertions(+), 78 deletions(-) create mode 100644 tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs diff --git a/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs b/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs index d1b24bbb50..e34ccf7c3b 100644 --- a/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs +++ b/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs @@ -709,8 +709,17 @@ .. userTokenCertificateGroup.CertificateTypes } keyCredNode.StartRequest!.OnCallAsync = OnKeyCredentialStartRequestAsync; + keyCredNode.StartRequest.OnReadRolePermissions = OnAddSelfAdminRolePermissions; + keyCredNode.StartRequest.OnReadUserRolePermissions = OnAddSelfAdminUserRolePermissions; keyCredNode.FinishRequest!.OnCallAsync = OnKeyCredentialFinishRequestAsync; + keyCredNode.FinishRequest.OnReadRolePermissions = OnAddSelfAdminRolePermissions; + keyCredNode.FinishRequest.OnReadUserRolePermissions = OnAddSelfAdminUserRolePermissions; keyCredNode.Revoke?.OnCallAsync = OnKeyCredentialRevokeAsync; + if (keyCredNode.Revoke != null) + { + keyCredNode.Revoke.OnReadRolePermissions = OnAddSelfAdminRolePermissions; + keyCredNode.Revoke.OnReadUserRolePermissions = OnAddSelfAdminUserRolePermissions; + } return keyCredNode; case ObjectTypes.AuthorizationServiceType: @@ -2595,18 +2604,38 @@ private async ValueTask OnKeyCredent ArrayOf requestedRoles, CancellationToken cancellationToken) { - AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + ArrayOf auditInputs = [applicationUri, publicKey, securityPolicyUri, requestedRoles]; + NodeId requestId; + try + { + AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + ByteString clientCertificateFingerprint = + AuthorizationHelper.GetClientCertificateFingerprint(context); + NodeId applicationId = ResolveKeyCredentialApplicationId(m_database, applicationUri); + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + applicationId); + IApplicationOwnedKeyCredentialRequestStore store = GetApplicationOwnedKeyCredentialStore(); - m_logger.OnKeyCredentialStartRequest(applicationUri); + m_logger.OnKeyCredentialStartRequest(applicationUri); - NodeId requestId = await KeyCredentialRequestStore.StartRequestAsync( - applicationUri, - publicKey, - securityPolicyUri, - requestedRoles, - cancellationToken).ConfigureAwait(false); + requestId = await store.StartOwnedBoundRequestAsync( + applicationUri, + applicationId, + publicKey, + securityPolicyUri, + requestedRoles, + clientCertificateFingerprint, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + Server.ReportKeyCredentialRequestedAuditEvent( + context, objectId, method, auditInputs, m_logger, ex); + throw; + } - ArrayOf auditInputs = [applicationUri, publicKey, securityPolicyUri, requestedRoles]; Server.ReportKeyCredentialRequestedAuditEvent( context, objectId, method, auditInputs, m_logger); @@ -2625,36 +2654,52 @@ private async ValueTask OnKeyCreden bool cancelRequest, CancellationToken cancellationToken) { - AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + ArrayOf auditInputs = [requestId, cancelRequest]; + try + { + AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + ByteString clientCertificateFingerprint = + AuthorizationHelper.GetClientCertificateFingerprint(context); + IApplicationOwnedKeyCredentialRequestStore store = GetApplicationOwnedKeyCredentialStore(); + NodeId applicationId = await store + .GetRequestApplicationIdAsync(requestId, cancellationToken) + .ConfigureAwait(false); + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + applicationId); - m_logger.OnKeyCredentialFinishRequest(requestId); + m_logger.OnKeyCredentialFinishRequest(requestId); - FinishKeyCredentialRequestResult finished = await KeyCredentialRequestStore.FinishRequestAsync( - requestId, - cancelRequest, - cancellationToken).ConfigureAwait(false); + FinishKeyCredentialRequestResult finished = + await store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest, + applicationId, + clientCertificateFingerprint, + cancellationToken).ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult result = + CreateKeyCredentialFinishResult(finished); - var result = new KeyCredentialFinishRequestMethodStateResult - { - CredentialId = finished.CredentialId ?? string.Empty, - CredentialSecret = finished.CredentialSecret, - CertificateThumbprint = finished.CertificateThumbprint ?? string.Empty, - SecurityPolicyUri = finished.SecurityPolicyUri ?? string.Empty, - GrantedRoles = finished.GrantedRoles - }; + if (finished.State == KeyCredentialRequestState.New) + { + var exception = new ServiceResultException(StatusCodes.BadRequestNotComplete); + Server.ReportKeyCredentialDeliveredAuditEvent( + context, objectId, method, auditInputs, m_logger, exception); + return result; + } + + Server.ReportKeyCredentialDeliveredAuditEvent( + context, objectId, method, auditInputs, m_logger); - if (finished.State == KeyCredentialRequestState.New) - { - result.ServiceResult = new ServiceResult(StatusCodes.BadNothingToDo); return result; } - - ArrayOf auditInputs = [requestId, cancelRequest]; - Server.ReportKeyCredentialDeliveredAuditEvent( - context, objectId, method, auditInputs, m_logger); - - result.ServiceResult = ServiceResult.Good; - return result; + catch (Exception ex) + { + Server.ReportKeyCredentialDeliveredAuditEvent( + context, objectId, method, auditInputs, m_logger, ex); + throw; + } } private async ValueTask OnKeyCredentialRevokeAsync( @@ -2664,19 +2709,115 @@ private async ValueTask OnKeyCredentialRev string credentialId, CancellationToken cancellationToken) { - AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + ArrayOf auditInputs = [credentialId]; + try + { + AuthorizationHelper.HasAuthenticatedSecureChannel(context, requireEncryption: true); + _ = AuthorizationHelper.GetClientCertificateFingerprint(context); + IApplicationOwnedKeyCredentialRequestStore store = GetApplicationOwnedKeyCredentialStore(); + NodeId applicationId = await store + .GetCredentialApplicationIdAsync(credentialId, cancellationToken) + .ConfigureAwait(false); + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + applicationId); - m_logger.OnKeyCredentialRevoke(credentialId); + m_logger.OnKeyCredentialRevoke(credentialId); - await KeyCredentialRequestStore.RevokeAsync(credentialId, cancellationToken).ConfigureAwait(false); + await store + .RevokeOwnedAsync(credentialId, applicationId, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Server.ReportKeyCredentialRevokedAuditEvent( + context, objectId, method, auditInputs, m_logger, ex); + throw; + } - ArrayOf auditInputs = [credentialId]; Server.ReportKeyCredentialRevokedAuditEvent( context, objectId, method, auditInputs, m_logger); return new KeyCredentialRevokeMethodStateResult { ServiceResult = ServiceResult.Good }; } + internal static NodeId ResolveKeyCredentialApplicationId( + IApplicationsDatabase database, + string applicationUri) + { + if (database == null) + { + throw new ArgumentNullException(nameof(database)); + } + if (string.IsNullOrWhiteSpace(applicationUri)) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + "The ApplicationUri is not known to the GDS."); + } + + ApplicationRecordDataType[] matches = database + .FindApplications(applicationUri)? + .Where(application => string.Equals( + application.ApplicationUri, + applicationUri, + StringComparison.Ordinal)) + .ToArray() ?? []; + if (matches.Length == 0) + { + throw new ServiceResultException( + StatusCodes.BadNotFound, + "The ApplicationUri is not known to the GDS."); + } + if (matches.Length != 1 || matches[0].ApplicationId.IsNull) + { + throw new ServiceResultException( + StatusCodes.BadConfigurationError, + "The ApplicationUri does not uniquely identify a registered application."); + } + + return matches[0].ApplicationId; + } + + internal static ServiceResult GetKeyCredentialFinishServiceResult( + KeyCredentialRequestState state) + { + return state == KeyCredentialRequestState.New + ? new ServiceResult(StatusCodes.BadRequestNotComplete) + : ServiceResult.Good; + } + + internal static KeyCredentialFinishRequestMethodStateResult CreateKeyCredentialFinishResult( + FinishKeyCredentialRequestResult finished) + { + if (finished.State == KeyCredentialRequestState.New) + { + return new KeyCredentialFinishRequestMethodStateResult + { + ServiceResult = GetKeyCredentialFinishServiceResult(finished.State) + }; + } + + return new KeyCredentialFinishRequestMethodStateResult + { + ServiceResult = ServiceResult.Good, + CredentialId = finished.CredentialId ?? string.Empty, + CredentialSecret = finished.CredentialSecret, + CertificateThumbprint = finished.CertificateThumbprint ?? string.Empty, + SecurityPolicyUri = finished.SecurityPolicyUri ?? string.Empty, + GrantedRoles = finished.GrantedRoles + }; + } + + private IApplicationOwnedKeyCredentialRequestStore GetApplicationOwnedKeyCredentialStore() + { + return KeyCredentialRequestStore as IApplicationOwnedKeyCredentialRequestStore ?? + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential store does not support application ownership checks."); + } + /// protected override void Dispose(bool disposing) { diff --git a/src/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs b/src/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs index 3b4879d986..54bd7dcee3 100644 --- a/src/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs +++ b/src/Opc.Ua.Gds.Server.Common/Diagnostics/AuditEvents.cs @@ -350,7 +350,7 @@ internal static void ReportKeyCredentialRequestedAuditEvent( server, systemContext, objectId, method, inputArguments, "KeyCredentialRequestedAuditEvent", static () => new KeyCredentialRequestedAuditEventState(null), - logger, exception); + logger, exception, redactExceptionMessage: true); } /// @@ -362,13 +362,14 @@ internal static void ReportKeyCredentialDeliveredAuditEvent( NodeId objectId, MethodState method, ArrayOf inputArguments, - ILogger logger) + ILogger logger, + Exception? exception = null) { ReportSimpleAuditEvent( server, systemContext, objectId, method, inputArguments, "KeyCredentialDeliveredAuditEvent", static () => new KeyCredentialDeliveredAuditEventState(null), - logger, null); + logger, exception, redactExceptionMessage: true); } /// @@ -387,7 +388,7 @@ internal static void ReportKeyCredentialRevokedAuditEvent( server, systemContext, objectId, method, inputArguments, "KeyCredentialRevokedAuditEvent", static () => new KeyCredentialRevokedAuditEventState(null), - logger, exception); + logger, exception, redactExceptionMessage: true); } /// @@ -406,7 +407,7 @@ internal static void ReportAccessTokenIssuedAuditEvent( server, systemContext, objectId, method, inputArguments, "AccessTokenIssuedAuditEvent", static () => new AccessTokenIssuedAuditEventState(null), - logger, exception); + logger, exception, redactExceptionMessage: false); } /// @@ -422,16 +423,19 @@ private static void ReportSimpleAuditEvent( string eventName, Func factory, ILogger logger, - Exception? exception) + Exception? exception, + bool redactExceptionMessage) { try { AuditUpdateMethodEventState e = factory(); - TranslationInfo message = exception == null - ? new TranslationInfo(eventName, "en-US", $"{eventName}.") - : new TranslationInfo(eventName, "en-US", - $"{eventName} - Exception: {exception.Message}."); + string messageText = exception == null + ? $"{eventName}." + : redactExceptionMessage + ? $"{eventName} failed." + : $"{eventName} - Exception: {exception.Message}."; + var message = new TranslationInfo(eventName, "en-US", messageText); e.Initialize( systemContext, null, EventSeverity.Min, diff --git a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs index 142c34ce44..03a759d156 100644 --- a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs +++ b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs @@ -71,10 +71,15 @@ public sealed class KeyCredentialRequestRecord public NodeId RequestId { get; set; } /// - /// The ApplicationUri that requested the credential. + /// The ApplicationUri of the application receiving the credential. /// public string? ApplicationUri { get; set; } + /// + /// The registered application receiving and owning the credential. + /// + public NodeId ApplicationId { get; set; } + /// /// The public key supplied by the requester. /// @@ -124,6 +129,12 @@ public sealed class KeyCredentialRequestRecord /// When the request was created. /// public DateTime CreatedAt { get; set; } + + /// + /// SHA-256 fingerprint of the SecureChannel client certificate that + /// initiated the request. + /// + public ByteString ClientCertificateFingerprint { get; set; } } /// @@ -175,6 +186,29 @@ ValueTask StartRequestAsync( ArrayOf requestedRoles, CancellationToken cancellationToken = default); +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + /// + /// Starts a request bound to the initiating SecureChannel client + /// certificate. + /// + /// + /// The default implementation rejects the request so legacy stores + /// cannot silently bypass certificate binding. + /// + ValueTask StartBoundRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential store does not support client certificate binding."); + } +#endif + /// /// Finish (approve or cancel) a key-credential request. /// @@ -190,6 +224,27 @@ ValueTask FinishRequestAsync( bool cancelRequest, CancellationToken cancellationToken = default); +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + /// + /// Finishes a request only when it is bound to the supplied + /// SecureChannel client certificate fingerprint. + /// + /// + /// The default implementation rejects the request so legacy stores + /// cannot silently bypass certificate binding. + /// + ValueTask FinishBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential store does not support client certificate binding."); + } +#endif + /// /// Revoke a previously issued credential. /// @@ -198,6 +253,162 @@ ValueTask FinishRequestAsync( ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default); } + /// + /// Optional compatibility surface for stores that enforce SecureChannel + /// client-certificate binding on TFMs without default interface methods. + /// + public interface IClientCertificateBoundKeyCredentialRequestStore : IKeyCredentialRequestStore + { + /// + /// Starts a request bound to the supplied client-certificate fingerprint. + /// +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + new ValueTask StartBoundRequestAsync( +#else + ValueTask StartBoundRequestAsync( +#endif + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default); + + /// + /// Finishes a request bound to the supplied client-certificate fingerprint. + /// +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + new ValueTask FinishBoundRequestAsync( +#else + ValueTask FinishBoundRequestAsync( +#endif + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default); + } + + /// + /// Store surface that preserves immutable registered-application ownership + /// for certificate-bound requests and credentials. + /// + public interface IApplicationOwnedKeyCredentialRequestStore : + IClientCertificateBoundKeyCredentialRequestStore + { + /// + /// Starts a certificate-bound request owned by a registered application. + /// + ValueTask StartOwnedBoundRequestAsync( + string applicationUri, + NodeId applicationId, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default); + + /// + /// Returns the immutable registered-application owner of a request. + /// + ValueTask GetRequestApplicationIdAsync( + NodeId requestId, + CancellationToken cancellationToken = default); + + /// + /// Finishes a certificate-bound request after atomically checking its + /// registered-application owner. + /// + ValueTask FinishOwnedBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + NodeId applicationId, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default); + + /// + /// Returns the immutable registered-application owner of a credential. + /// + ValueTask GetCredentialApplicationIdAsync( + string credentialId, + CancellationToken cancellationToken = default); + + /// + /// Revokes a credential after atomically checking its + /// registered-application owner. + /// + ValueTask RevokeOwnedAsync( + string credentialId, + NodeId applicationId, + CancellationToken cancellationToken = default); + } + + internal static class KeyCredentialRequestStoreBinding + { + public static ValueTask StartBoundRequestCompatAsync( + this IKeyCredentialRequestStore store, + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken) + { +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + return store.StartBoundRequestAsync( + applicationUri, + publicKey, + securityPolicyUri, + requestedRoles, + clientCertificateFingerprint, + cancellationToken); +#else + if (store is IClientCertificateBoundKeyCredentialRequestStore boundStore) + { + return boundStore.StartBoundRequestAsync( + applicationUri, + publicKey, + securityPolicyUri, + requestedRoles, + clientCertificateFingerprint, + cancellationToken); + } + + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential store does not support client certificate binding."); +#endif + } + + public static ValueTask FinishBoundRequestCompatAsync( + this IKeyCredentialRequestStore store, + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken) + { +#if NETSTANDARD2_1 || NET8_0_OR_GREATER + return store.FinishBoundRequestAsync( + requestId, + cancelRequest, + clientCertificateFingerprint, + cancellationToken); +#else + if (store is IClientCertificateBoundKeyCredentialRequestStore boundStore) + { + return boundStore.FinishBoundRequestAsync( + requestId, + cancelRequest, + clientCertificateFingerprint, + cancellationToken); + } + + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential store does not support client certificate binding."); +#endif + } + } + /// /// In-memory implementation of . /// Suitable for testing and single-process GDS deployments. @@ -209,8 +420,10 @@ ValueTask FinishRequestAsync( /// Kubernetes secrets, or any other backend that implements /// without changing the GDS code. /// - public sealed class InMemoryKeyCredentialRequestStore : IKeyCredentialRequestStore + public sealed class InMemoryKeyCredentialRequestStore : IApplicationOwnedKeyCredentialRequestStore { + private const int Sha256FingerprintLength = 32; + private int m_nextId; private readonly ConcurrentDictionary m_requests = new(); private readonly ConcurrentDictionary m_credentials = new(StringComparer.Ordinal); @@ -239,12 +452,73 @@ public InMemoryKeyCredentialRequestStore(ISecretStore secretStore) } /// - public async ValueTask StartRequestAsync( + public ValueTask StartRequestAsync( string applicationUri, ByteString publicKey, string? securityPolicyUri, ArrayOf requestedRoles, CancellationToken cancellationToken = default) + { + return StartRequestCoreAsync( + applicationUri, + default, + publicKey, + securityPolicyUri, + requestedRoles, + default, + cancellationToken); + } + + /// + public ValueTask StartBoundRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + ValidateClientCertificateFingerprint(clientCertificateFingerprint); + return StartRequestCoreAsync( + applicationUri, + default, + publicKey, + securityPolicyUri, + requestedRoles, + ByteString.From(clientCertificateFingerprint.ToArray()), + cancellationToken); + } + + /// + public ValueTask StartOwnedBoundRequestAsync( + string applicationUri, + NodeId applicationId, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + ValidateApplicationId(applicationId); + ValidateClientCertificateFingerprint(clientCertificateFingerprint); + return StartRequestCoreAsync( + applicationUri, + applicationId, + publicKey, + securityPolicyUri, + requestedRoles, + ByteString.From(clientCertificateFingerprint.ToArray()), + cancellationToken); + } + + private async ValueTask StartRequestCoreAsync( + string applicationUri, + NodeId applicationId, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken) { int id = Interlocked.Increment(ref m_nextId); var requestId = new NodeId((uint)id); @@ -259,6 +533,7 @@ public async ValueTask StartRequestAsync( { RequestId = requestId, ApplicationUri = applicationUri, + ApplicationId = applicationId, PublicKey = publicKey, SecurityPolicyUri = securityPolicyUri, RequestedRoles = requestedRoles, @@ -268,7 +543,8 @@ public async ValueTask StartRequestAsync( CredentialSecret = ByteString.From(secretBytes), CertificateThumbprint = null, GrantedSecurityPolicyUri = securityPolicyUri, - GrantedRoles = requestedRoles + GrantedRoles = requestedRoles, + ClientCertificateFingerprint = clientCertificateFingerprint }; m_requests[requestId] = record; @@ -277,18 +553,65 @@ public async ValueTask StartRequestAsync( } /// - public async ValueTask FinishRequestAsync( + public ValueTask FinishRequestAsync( NodeId requestId, bool cancelRequest, CancellationToken cancellationToken = default) { - if (!m_requests.TryGetValue(requestId, out KeyCredentialRequestRecord? record)) + KeyCredentialRequestRecord record = GetRequest(requestId); + if (!record.ClientCertificateFingerprint.IsEmpty) { throw new ServiceResultException( - StatusCodes.BadNotFound, - "The RequestId is not known."); + StatusCodes.BadSecurityChecksFailed, + "A client certificate fingerprint is required to finish the bound request."); } + return FinishRequestCoreAsync(record, cancelRequest, cancellationToken); + } + + /// + public ValueTask FinishBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + ValidateClientCertificateFingerprint(clientCertificateFingerprint); + KeyCredentialRequestRecord record = GetRequest(requestId); + ValidateRequestBinding(record, clientCertificateFingerprint); + return FinishRequestCoreAsync(record, cancelRequest, cancellationToken); + } + + /// + public ValueTask GetRequestApplicationIdAsync( + NodeId requestId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + KeyCredentialRequestRecord record = GetRequest(requestId, StatusCodes.BadInvalidArgument); + return new ValueTask(GetApplicationId(record)); + } + /// + public ValueTask FinishOwnedBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + NodeId applicationId, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + ValidateApplicationId(applicationId); + ValidateClientCertificateFingerprint(clientCertificateFingerprint); + KeyCredentialRequestRecord record = GetRequest(requestId, StatusCodes.BadInvalidArgument); + ValidateApplicationOwnership(record, applicationId); + ValidateRequestBinding(record, clientCertificateFingerprint); + return FinishRequestCoreAsync(record, cancelRequest, cancellationToken); + } + + private async ValueTask FinishRequestCoreAsync( + KeyCredentialRequestRecord record, + bool cancelRequest, + CancellationToken cancellationToken) + { if (cancelRequest) { record.State = KeyCredentialRequestState.Rejected; @@ -325,19 +648,46 @@ public async ValueTask FinishRequestAsync( } /// - public async ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default) + public ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default) { - if (!m_credentials.TryGetValue(credentialId, out KeyCredentialRequestRecord? record)) - { - throw new ServiceResultException( - StatusCodes.BadNotFound, - "The CredentialId is not known."); - } + KeyCredentialRequestRecord record = GetCredential(credentialId, StatusCodes.BadNotFound); + return RevokeCoreAsync(record, cancellationToken); + } + + /// + public ValueTask GetCredentialApplicationIdAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + KeyCredentialRequestRecord record = GetCredential( + credentialId, + StatusCodes.BadInvalidArgument); + return new ValueTask(GetApplicationId(record)); + } + + /// + public ValueTask RevokeOwnedAsync( + string credentialId, + NodeId applicationId, + CancellationToken cancellationToken = default) + { + ValidateApplicationId(applicationId); + KeyCredentialRequestRecord record = GetCredential( + credentialId, + StatusCodes.BadInvalidArgument); + ValidateApplicationOwnership(record, applicationId); + return RevokeCoreAsync(record, cancellationToken); + } + private async ValueTask RevokeCoreAsync( + KeyCredentialRequestRecord record, + CancellationToken cancellationToken) + { record.State = KeyCredentialRequestState.Rejected; // purge the secret from the backing store - var secretId = new SecretIdentifier(credentialId, m_secretStore.StoreType); + var secretId = new SecretIdentifier(record.CredentialId!, m_secretStore.StoreType); await m_secretStore.RemoveAsync(secretId, cancellationToken).ConfigureAwait(false); } @@ -348,5 +698,99 @@ private static byte[] GenerateRandomBytes(int length) rng.GetBytes(buffer); return buffer; } + + private KeyCredentialRequestRecord GetRequest(NodeId requestId) + { + return GetRequest(requestId, StatusCodes.BadNotFound); + } + + private KeyCredentialRequestRecord GetRequest(NodeId requestId, StatusCode statusCode) + { + if (m_requests.TryGetValue(requestId, out KeyCredentialRequestRecord? record)) + { + return record; + } + + throw new ServiceResultException( + statusCode, + "The RequestId is not known."); + } + + private KeyCredentialRequestRecord GetCredential(string credentialId, StatusCode statusCode) + { + if (m_credentials.TryGetValue(credentialId, out KeyCredentialRequestRecord? record)) + { + return record; + } + + throw new ServiceResultException( + statusCode, + "The CredentialId is not known."); + } + + private static NodeId GetApplicationId(KeyCredentialRequestRecord record) + { + if (!record.ApplicationId.IsNull) + { + return record.ApplicationId; + } + + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential record has no registered-application owner."); + } + + private static void ValidateApplicationId(NodeId applicationId) + { + if (applicationId.IsNull) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "A registered-application owner is required."); + } + } + + private static void ValidateApplicationOwnership( + KeyCredentialRequestRecord record, + NodeId applicationId) + { + if (GetApplicationId(record) != applicationId) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential record owner changed during authorization."); + } + } + + private static void ValidateClientCertificateFingerprint(ByteString clientCertificateFingerprint) + { + if (clientCertificateFingerprint.Length != Sha256FingerprintLength) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "A valid SHA-256 client certificate fingerprint is required."); + } + } + + private static void ValidateRequestBinding( + KeyCredentialRequestRecord record, + ByteString clientCertificateFingerprint) + { + if (record.ClientCertificateFingerprint.Length != Sha256FingerprintLength) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential request has no client certificate binding."); + } + + if (!CryptoUtils.FixedTimeEquals( + record.ClientCertificateFingerprint.Span, + clientCertificateFingerprint.Span)) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "The key-credential request belongs to a different client certificate."); + } + } } } diff --git a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs index 7f5f4f93af..764083b8d8 100644 --- a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs +++ b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs @@ -30,6 +30,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Security.Cryptography; using Opc.Ua.Gds.Server.Database; using Opc.Ua.Server; @@ -39,6 +40,18 @@ internal static class AuthorizationHelper { internal static List AuthenticatedUser { get; } = [Role.AuthenticatedUser]; internal static List DiscoveryAdmin { get; } = [GdsRole.DiscoveryAdmin]; + internal static List KeyCredentialAdmin { get; } = [GdsRole.KeyCredentialAdmin]; + + /// + /// Roles and privileges accepted by KeyCredential StartRequest, + /// FinishRequest and Revoke per OPC 10000-12 §8.5.5 - §8.5.7. + /// + internal static List KeyCredentialAdminOrSelfAdminOrAppAdmin { get; } = + [ + GdsRole.KeyCredentialAdmin, + GdsRole.ApplicationSelfAdmin, + GdsRole.ApplicationAdmin + ]; /// /// Roles/privileges accepted by RegisterApplication @@ -211,19 +224,7 @@ public static void HasAuthenticatedSecureChannel( ISystemContext context, bool requireEncryption = false) { - // Extract the OperationContext from whichever concrete - // system context type is in use. ServerSystemContext - // extends SessionSystemContext (server path); SystemContext - // is the standalone variant (Opc.Ua.Types). - OperationContext? operationContext = context switch - { - SessionSystemContext sc => sc.OperationContext as OperationContext, - SystemContext sc => sc.OperationContext as OperationContext, - _ => null - } ?? - throw new ServiceResultException( - StatusCodes.BadSecurityModeInsufficient, - "Unable to verify secure channel requirements."); + OperationContext operationContext = GetOperationContext(context); MessageSecurityMode securityMode = operationContext .ChannelContext? @@ -246,6 +247,54 @@ public static void HasAuthenticatedSecureChannel( } } + /// + /// Returns the SHA-256 fingerprint of the client certificate that + /// established the current SecureChannel. + /// + /// + /// Thrown with when + /// the request is not associated with a client certificate. + /// + public static ByteString GetClientCertificateFingerprint(ISystemContext context) + { + OperationContext operationContext = GetOperationContext(context); + byte[] clientCertificate = operationContext.ChannelContext?.ClientChannelCertificate ?? + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "Unable to bind the request to a SecureChannel client certificate."); + + if (clientCertificate.Length == 0) + { + throw new ServiceResultException( + StatusCodes.BadSecurityChecksFailed, + "Unable to bind the request to a SecureChannel client certificate."); + } + +#if NET6_0_OR_GREATER + return ByteString.From(SHA256.HashData(clientCertificate)); +#else + using SHA256 sha256 = SHA256.Create(); + return ByteString.From(sha256.ComputeHash(clientCertificate)); +#endif + } + + private static OperationContext GetOperationContext(ISystemContext context) + { + // Extract the OperationContext from whichever concrete + // system context type is in use. ServerSystemContext + // extends SessionSystemContext (server path); SystemContext + // is the standalone variant (Opc.Ua.Types). + return context switch + { + SessionSystemContext sc => sc.OperationContext as OperationContext, + SystemContext sc => sc.OperationContext as OperationContext, + _ => null + } ?? + throw new ServiceResultException( + StatusCodes.BadSecurityModeInsufficient, + "Unable to verify secure channel requirements."); + } + private static bool HasRole(IUserIdentity? userIdentity, IEnumerable roles, NamespaceTable namespaces) { if (userIdentity != null && userIdentity.TokenType != UserTokenType.Anonymous) diff --git a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/GdsRole.cs b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/GdsRole.cs index 2335c886f0..8f6cba1b57 100644 --- a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/GdsRole.cs +++ b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/GdsRole.cs @@ -64,6 +64,13 @@ public class GdsRole : Role ObjectIds.WellKnownRole_RegistrationAuthorityAdmin, BrowseNames.WellKnownRole_RegistrationAuthorityAdmin); + /// + /// This Role grants rights to request or revoke any KeyCredential. + /// + public static Role KeyCredentialAdmin { get; } = new( + ObjectIds.WellKnownRole_KeyCredentialAdmin, + BrowseNames.WellKnownRole_KeyCredentialAdmin); + /// /// A privilege to manage the own Certificates and pull trust list. /// diff --git a/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs b/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs index d6e0f31ca4..022a0d0f1b 100644 --- a/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs +++ b/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs @@ -27,8 +27,14 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; using NUnit.Framework; using Opc.Ua.Gds.Server.Diagnostics; +using Opc.Ua.Server; +using Opc.Ua.Tests; +using GdsAuditEvents = Opc.Ua.Gds.Server.Diagnostics.AuditEvents; namespace Opc.Ua.Gds.Tests { @@ -50,7 +56,7 @@ public void RedactedPrivateKeyPasswordIsStablePlaceholder() // The placeholder is part of the public contract; downstream // audit consumers may filter on this exact value to spot // redacted entries. Do not change the value. - Assert.That(AuditEvents.RedactedPrivateKeyPassword, Is.EqualTo("")); + Assert.That(GdsAuditEvents.RedactedPrivateKeyPassword, Is.EqualTo("")); } [Test] @@ -59,7 +65,56 @@ public void RedactedPrivateKeyIsEmptyByteString() // Private keys must never appear in audit payloads; the // helper exposes an empty ByteString as the standard // placeholder. - Assert.That(AuditEvents.RedactedPrivateKey.IsEmpty, Is.True); + Assert.That(GdsAuditEvents.RedactedPrivateKey.IsEmpty, Is.True); + } + + [Test] + public void KeyCredentialFailureAuditRedactsExceptionDetails() + { + const string secret = "credential-secret-must-not-appear"; + var auditServer = new CapturingAuditEventServer(); + ILogger logger = NUnitTelemetryContext.Create().CreateLogger(); + + auditServer.ReportKeyCredentialDeliveredAuditEvent( + auditServer.DefaultAuditContext, + Ua.ObjectIds.Server, + new MethodState(null), + [new NodeId(1), false], + logger, + new InvalidOperationException($"Store failed with {secret}.")); + + Assert.That(auditServer.Events, Has.Count.EqualTo(1)); + AuditUpdateMethodEventState auditEvent = auditServer.Events[0]; + Assert.That(auditEvent.Status!.Value, Is.False); + Assert.That(auditEvent.Message!.Value.Text, Does.Not.Contain(secret)); + Assert.That(auditEvent.Message.Value.Text, Is.EqualTo("KeyCredentialDeliveredAuditEvent failed.")); + } + + private sealed class CapturingAuditEventServer : IAuditEventServer + { + public CapturingAuditEventServer() + { + var context = new SystemContext(NUnitTelemetryContext.Create()) + { + NamespaceUris = new NamespaceTable() + }; + context.NamespaceUris.GetIndexOrAppend(Namespaces.OpcUaGds); + DefaultAuditContext = context; + } + + public bool Auditing => true; + + public ISystemContext DefaultAuditContext { get; } + + public List Events { get; } = []; + + public void ReportAuditEvent(ISystemContext context, AuditEventState e) + { + if (e is AuditUpdateMethodEventState auditEvent) + { + Events.Add(auditEvent); + } + } } } } diff --git a/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs b/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs index 4c178fce44..c71dda20a2 100644 --- a/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs +++ b/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs @@ -28,6 +28,7 @@ * ======================================================================*/ using System.Collections.Generic; +using System.Security.Cryptography; using System.Text; using NUnit.Framework; using Opc.Ua.Gds.Server; @@ -209,6 +210,88 @@ public void HasAuthorizationSucceedsWithCertificateAuthorityAdminRole() AuthorizationHelper.CertificateAuthorityAdmin)); } + [Test] + public void HasAuthorizationSucceedsWithKeyCredentialAdminRole() + { + var innerIdentity = new UserIdentity("keyadmin", s_passwordBytes); + var roles = new List { GdsRole.KeyCredentialAdmin }; + var identity = new GdsRoleBasedIdentity(innerIdentity, roles, m_namespaceTable); + + var context = new SessionSystemContext(m_telemetry) + { + UserIdentity = identity, + NamespaceUris = m_namespaceTable + }; + + Assert.DoesNotThrow(() => + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdmin)); + } + + [Test] + public void KeyCredentialAuthorizationAllowsSelfAdminOnlyForOwnApplication() + { + var ownAppId = new NodeId(99); + var otherAppId = new NodeId(100); + var identity = new GdsRoleBasedIdentity( + new UserIdentity("appuser", s_passwordBytes), + [GdsRole.ApplicationSelfAdmin], + ownAppId, + m_namespaceTable); + var context = new SessionSystemContext(m_telemetry) + { + UserIdentity = identity, + NamespaceUris = m_namespaceTable + }; + + Assert.DoesNotThrow(() => + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + ownAppId)); + Assert.That( + () => AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + otherAppId), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadUserAccessDenied)); + } + + [Test] + public void KeyCredentialAuthorizationAllowsApplicationAdminOnlyForManagedApplication() + { + var managedAppId = new NodeId(11); + var unmanagedAppId = new NodeId(12); + var identity = new GdsRoleBasedIdentity( + new UserIdentity("agent", s_passwordBytes), + [GdsRole.ApplicationAdmin], + NodeId.Null, + [managedAppId], + m_namespaceTable); + var context = new SessionSystemContext(m_telemetry) + { + UserIdentity = identity, + NamespaceUris = m_namespaceTable + }; + + Assert.DoesNotThrow(() => + AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + managedAppId)); + Assert.That( + () => AuthorizationHelper.HasAuthorization( + context, + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + unmanagedAppId), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadUserAccessDenied)); + } + [Test] public void HasAuthenticatedSecureChannelThrowsForNonSystemContext() { @@ -320,6 +403,72 @@ public void HasAuthenticatedSecureChannelDoesNotThrowForSignAndEncrypt() AuthorizationHelper.HasAuthenticatedSecureChannel(context)); } + [Test] + public void GetClientCertificateFingerprintReturnsSha256() + { + byte[] clientCertificate = [1, 2, 3, 4, 5]; + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt + }; + var channelContext = new SecureChannelContext( + "test-channel", + endpoint, + RequestEncoding.Binary, + clientCertificate); + var operationContext = new OperationContext( + new RequestHeader(), + channelContext, + RequestType.Call, + RequestLifetime.None); + var context = new SystemContext(operationContext, m_telemetry) + { + NamespaceUris = m_namespaceTable + }; + +#if NET6_0_OR_GREATER + byte[] expected = SHA256.HashData(clientCertificate); +#else + byte[] expected; + using (SHA256 sha256 = SHA256.Create()) + { + expected = sha256.ComputeHash(clientCertificate); + } +#endif + + ByteString fingerprint = AuthorizationHelper.GetClientCertificateFingerprint(context); + + Assert.That(fingerprint.ToArray(), Is.EqualTo(expected)); + } + + [Test] + public void GetClientCertificateFingerprintThrowsWhenCertificateIsMissing() + { + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt + }; + var channelContext = new SecureChannelContext( + "test-channel", + endpoint, + RequestEncoding.Binary); + var operationContext = new OperationContext( + new RequestHeader(), + channelContext, + RequestType.Call, + RequestLifetime.None); + var context = new SystemContext(operationContext, m_telemetry) + { + NamespaceUris = m_namespaceTable + }; + + Assert.That( + () => AuthorizationHelper.GetClientCertificateFingerprint(context), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + [Test] public void StaticRoleListsAreCorrectlyPopulated() { @@ -329,6 +478,19 @@ public void StaticRoleListsAreCorrectlyPopulated() Assert.That(AuthorizationHelper.DiscoveryAdmin, Has.Count.EqualTo(1)); Assert.That(AuthorizationHelper.DiscoveryAdmin[0], Is.EqualTo(GdsRole.DiscoveryAdmin)); + Assert.That(AuthorizationHelper.KeyCredentialAdmin, Has.Count.EqualTo(1)); + Assert.That(AuthorizationHelper.KeyCredentialAdmin[0], + Is.EqualTo(GdsRole.KeyCredentialAdmin)); + + Assert.That( + AuthorizationHelper.KeyCredentialAdminOrSelfAdminOrAppAdmin, + Is.EquivalentTo( + [ + GdsRole.KeyCredentialAdmin, + GdsRole.ApplicationSelfAdmin, + GdsRole.ApplicationAdmin + ])); + Assert.That(AuthorizationHelper.DiscoveryAdminOrSelfAdmin, Has.Count.EqualTo(2)); Assert.That(AuthorizationHelper.DiscoveryAdminOrSelfAdmin, Does.Contain(GdsRole.DiscoveryAdmin)); diff --git a/tests/Opc.Ua.Gds.Tests/GdsTestFixture.cs b/tests/Opc.Ua.Gds.Tests/GdsTestFixture.cs index 29e60b34fd..802bd41d86 100644 --- a/tests/Opc.Ua.Gds.Tests/GdsTestFixture.cs +++ b/tests/Opc.Ua.Gds.Tests/GdsTestFixture.cs @@ -81,6 +81,7 @@ public async Task OneTimeSetUp() GdsRole.DiscoveryAdmin, GdsRole.CertificateAuthorityAdmin, GdsRole.RegistrationAuthorityAdmin, + GdsRole.KeyCredentialAdmin, Role.SecurityAdmin, Role.AuthenticatedUser ]); diff --git a/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs b/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs new file mode 100644 index 0000000000..4abf72d2aa --- /dev/null +++ b/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs @@ -0,0 +1,140 @@ +/* ======================================================================== + * 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(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(MockBehavior.Strict); + database + .Setup(db => db.FindApplications(ApplicationUri)) + .Returns([]); + + Assert.That( + () => ApplicationsNodeManager.ResolveKeyCredentialApplicationId( + database.Object, + ApplicationUri), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public void DuplicateApplicationUriReturnsBadConfigurationError() + { + var database = new Mock(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() + .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); + + 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 CompletedFinishReturnsGood() + { + ServiceResult result = ApplicationsNodeManager.GetKeyCredentialFinishServiceResult( + KeyCredentialRequestState.Completed); + + Assert.That(ServiceResult.IsGood(result), Is.True); + } + } +} diff --git a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs index 9f2371b434..823fef4d75 100644 --- a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs +++ b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs @@ -27,6 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using Opc.Ua.Gds.Server; @@ -84,6 +85,206 @@ public async Task FinishRequestReturnsCredentialAsync() Assert.That(result.CredentialSecret.IsEmpty, Is.False); } + [Test] + public async Task BoundRequestFinishesWithInitiatingCertificateAsync() + { + ByteString fingerprint = CreateFingerprint(1); + NodeId id = await m_store.StartBoundRequestAsync( + "urn:test:app", + ByteString.From(new byte[] { 1, 2 }), + null, + default, + fingerprint).ConfigureAwait(false); + + FinishKeyCredentialRequestResult result = await m_store.FinishBoundRequestAsync( + id, + cancelRequest: false, + fingerprint).ConfigureAwait(false); + + Assert.That(result.State, Is.EqualTo(KeyCredentialRequestState.Completed)); + Assert.That(result.CredentialSecret.IsEmpty, Is.False); + } + + [Test] + public async Task OwnedRequestPreservesApplicationOwnershipAsync() + { + var applicationId = new NodeId(42); + ByteString fingerprint = CreateFingerprint(1); + NodeId requestId = await m_store.StartOwnedBoundRequestAsync( + "urn:test:app", + applicationId, + default, + null, + default, + fingerprint).ConfigureAwait(false); + + Assert.That( + await m_store.GetRequestApplicationIdAsync(requestId).ConfigureAwait(false), + Is.EqualTo(applicationId)); + + FinishKeyCredentialRequestResult result = await m_store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest: false, + applicationId, + fingerprint).ConfigureAwait(false); + + Assert.That( + await m_store + .GetCredentialApplicationIdAsync(result.CredentialId!) + .ConfigureAwait(false), + Is.EqualTo(applicationId)); + Assert.That( + async () => await m_store + .RevokeOwnedAsync(result.CredentialId!, new NodeId(43)) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That( + async () => await m_store + .RevokeOwnedAsync(result.CredentialId!, applicationId) + .ConfigureAwait(false), + Throws.Nothing); + } + + [Test] + public async Task OwnedFinishRejectsDifferentApplicationAsync() + { + var applicationId = new NodeId(42); + ByteString fingerprint = CreateFingerprint(1); + NodeId requestId = await m_store.StartOwnedBoundRequestAsync( + "urn:test:app", + applicationId, + default, + null, + default, + fingerprint).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest: false, + new NodeId(43), + fingerprint).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task OwnershipLookupRejectsLegacyUnownedRecordAsync() + { + NodeId requestId = await m_store.StartBoundRequestAsync( + "urn:test:app", + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + + Assert.That( + async () => await m_store + .GetRequestApplicationIdAsync(requestId) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void OwnershipLookupUsesConformantInvalidArgumentStatus() + { + Assert.That( + async () => await m_store + .GetRequestApplicationIdAsync(new NodeId(999)) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadInvalidArgument)); + Assert.That( + async () => await m_store + .GetCredentialApplicationIdAsync("unknown-credential") + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public async Task BoundRequestRejectsDifferentCertificateAsync() + { + NodeId id = await m_store.StartBoundRequestAsync( + "urn:test:app", + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishBoundRequestAsync( + id, + cancelRequest: false, + CreateFingerprint(2)).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task LegacyFinishRejectsBoundRequestAsync() + { + NodeId id = await m_store.StartBoundRequestAsync( + "urn:test:app", + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishRequestAsync( + id, + cancelRequest: false).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task BoundFinishRejectsLegacyUnboundRequestAsync() + { + NodeId id = await m_store.StartRequestAsync( + "urn:test:app", + default, + null, + default).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishBoundRequestAsync( + id, + cancelRequest: false, + CreateFingerprint(1)).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public void BindingCompatibilityRejectsLegacyStore() + { + IKeyCredentialRequestStore store = new LegacyKeyCredentialRequestStore(); + + Assert.That( + async () => await store.StartBoundRequestCompatAsync( + "urn:test:app", + default, + null, + default, + CreateFingerprint(1), + CancellationToken.None).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + [Test] public async Task FinishRequestWithCancelRejectsRequestAsync() { @@ -148,5 +349,44 @@ public async Task MultipleRequestsGetUniqueIdsAsync() Assert.That(id1, Is.Not.EqualTo(id2)); } + + private static ByteString CreateFingerprint(byte value) + { + byte[] fingerprint = new byte[32]; + fingerprint[0] = value; + return ByteString.From(fingerprint); + } + + private sealed class LegacyKeyCredentialRequestStore : IKeyCredentialRequestStore + { + public ValueTask StartRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + CancellationToken cancellationToken = default) + { + return new ValueTask(new NodeId(1)); + } + + public ValueTask FinishRequestAsync( + NodeId requestId, + bool cancelRequest, + CancellationToken cancellationToken = default) + { + return new ValueTask( + new FinishKeyCredentialRequestResult + { + State = KeyCredentialRequestState.Completed + }); + } + + public ValueTask RevokeAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + return default; + } + } } } From 511938f686e933bc2e6272c04616c3ad5fd50d93 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Sun, 19 Jul 2026 18:10:04 +0200 Subject: [PATCH 2/3] Address KeyCredential review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs | 6 ++++-- .../RoleBasedUserManagement/AuthorizationHelper.cs | 6 ++++-- tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs index 03a759d156..f062e22a66 100644 --- a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs +++ b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs @@ -650,7 +650,9 @@ private async ValueTask FinishRequestCoreAsync /// public ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default) { - KeyCredentialRequestRecord record = GetCredential(credentialId, StatusCodes.BadNotFound); + KeyCredentialRequestRecord record = GetCredential( + credentialId, + StatusCodes.BadInvalidArgument); return RevokeCoreAsync(record, cancellationToken); } @@ -758,7 +760,7 @@ private static void ValidateApplicationOwnership( { throw new ServiceResultException( StatusCodes.BadSecurityChecksFailed, - "The key-credential record owner changed during authorization."); + "The key-credential record is owned by a different registered application."); } } diff --git a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs index 764083b8d8..3f7237f1c2 100644 --- a/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs +++ b/src/Opc.Ua.Gds.Server.Common/RoleBasedUserManagement/AuthorizationHelper.cs @@ -252,8 +252,10 @@ public static void HasAuthenticatedSecureChannel( /// established the current SecureChannel. /// /// - /// Thrown with when - /// the request is not associated with a client certificate. + /// Thrown with + /// when the operation context cannot be verified, or with + /// when the request + /// is not associated with a client certificate. /// public static ByteString GetClientCertificateFingerprint(ISystemContext context) { diff --git a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs index 823fef4d75..39b3040bf1 100644 --- a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs +++ b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs @@ -332,13 +332,13 @@ public async Task RevokeMarksCredentialAsRejectedAsync() } [Test] - public void RevokeUnknownCredentialThrows() + public void RevokeUnknownCredentialReturnsBadInvalidArgument() { Assert.That( async () => await m_store.RevokeAsync("unknown-credential").ConfigureAwait(false), Throws.TypeOf() .With.Property(nameof(ServiceResultException.StatusCode)) - .EqualTo(StatusCodes.BadNotFound)); + .EqualTo(StatusCodes.BadInvalidArgument)); } [Test] From 294d83ab4ef390f8f20a74a8377c05a051cc349b Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Mon, 20 Jul 2026 20:44:28 +0200 Subject: [PATCH 3/3] Improve KeyCredential coverage and state handling --- .../ApplicationsNodeManager.cs | 30 +- .../IKeyCredentialRequestStore.cs | 2 + ...plicationsNodeManagerKeyCredentialTests.cs | 639 ++++++++++++++++++ tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs | 96 +++ .../AuthorizationHelperTests.cs | 31 +- .../KeyCredentialConformanceTests.cs | 168 ++++- .../KeyCredentialRequestStoreTests.cs | 304 +++++++++ 7 files changed, 1255 insertions(+), 15 deletions(-) create mode 100644 tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerKeyCredentialTests.cs diff --git a/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs b/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs index e34ccf7c3b..e48585e86b 100644 --- a/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs +++ b/src/Opc.Ua.Gds.Server.Common/ApplicationsNodeManager.cs @@ -2679,11 +2679,11 @@ await store.FinishOwnedBoundRequestAsync( clientCertificateFingerprint, cancellationToken).ConfigureAwait(false); KeyCredentialFinishRequestMethodStateResult result = - CreateKeyCredentialFinishResult(finished); + CreateKeyCredentialFinishResult(finished, cancelRequest); - if (finished.State == KeyCredentialRequestState.New) + if (StatusCode.IsBad(result.ServiceResult.StatusCode)) { - var exception = new ServiceResultException(StatusCodes.BadRequestNotComplete); + var exception = new ServiceResultException(result.ServiceResult); Server.ReportKeyCredentialDeliveredAuditEvent( context, objectId, method, auditInputs, m_logger, exception); return result; @@ -2781,21 +2781,31 @@ internal static NodeId ResolveKeyCredentialApplicationId( } internal static ServiceResult GetKeyCredentialFinishServiceResult( - KeyCredentialRequestState state) + KeyCredentialRequestState state, + bool cancelRequest) { - return state == KeyCredentialRequestState.New - ? new ServiceResult(StatusCodes.BadRequestNotComplete) - : ServiceResult.Good; + if (state == KeyCredentialRequestState.New) + { + return new ServiceResult(StatusCodes.BadRequestNotComplete); + } + if (state == KeyCredentialRequestState.Rejected && !cancelRequest) + { + return new ServiceResult(StatusCodes.BadRequestNotAllowed); + } + return ServiceResult.Good; } internal static KeyCredentialFinishRequestMethodStateResult CreateKeyCredentialFinishResult( - FinishKeyCredentialRequestResult finished) + FinishKeyCredentialRequestResult finished, + bool cancelRequest) { - if (finished.State == KeyCredentialRequestState.New) + ServiceResult serviceResult = + GetKeyCredentialFinishServiceResult(finished.State, cancelRequest); + if (StatusCode.IsBad(serviceResult.StatusCode) || cancelRequest) { return new KeyCredentialFinishRequestMethodStateResult { - ServiceResult = GetKeyCredentialFinishServiceResult(finished.State) + ServiceResult = serviceResult }; } diff --git a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs index f062e22a66..9c6b091461 100644 --- a/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs +++ b/src/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs @@ -621,6 +621,7 @@ private async ValueTask FinishRequestCoreAsync var secretId = new SecretIdentifier(record.CredentialId, m_secretStore.StoreType); await m_secretStore.RemoveAsync(secretId, cancellationToken).ConfigureAwait(false); } + record.CredentialSecret = default; return new FinishKeyCredentialRequestResult { State = record.State }; } @@ -691,6 +692,7 @@ private async ValueTask RevokeCoreAsync( // purge the secret from the backing store var secretId = new SecretIdentifier(record.CredentialId!, m_secretStore.StoreType); await m_secretStore.RemoveAsync(secretId, cancellationToken).ConfigureAwait(false); + record.CredentialSecret = default; } private static byte[] GenerateRandomBytes(int length) diff --git a/tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerKeyCredentialTests.cs b/tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerKeyCredentialTests.cs new file mode 100644 index 0000000000..2af74456b5 --- /dev/null +++ b/tests/Opc.Ua.Gds.Tests/ApplicationsNodeManagerKeyCredentialTests.cs @@ -0,0 +1,639 @@ +/* ======================================================================== + * 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.Text; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using Opc.Ua.Gds.Server; +using Opc.Ua.Gds.Server.Database.Linq; +using Opc.Ua.Server; + +namespace Opc.Ua.Gds.Tests +{ + [TestFixture] + [Category("KeyCredential")] + [Category("ApplicationsNodeManagerCoverage")] + [NonParallelizable] + public sealed class ApplicationsNodeManagerKeyCredentialTests : GdsTestFixture + { + private const string OwnerApplicationUri = + "urn:opcfoundation.org:tests:test:app:KeyCredentialOwner"; + + private const string OtherApplicationUri = + "urn:opcfoundation.org:tests:test:app:KeyCredentialOther"; + + private static readonly byte[] s_passwordBytes = Encoding.UTF8.GetBytes("password"); + + private TestableApplicationsNodeManager m_nodeManager = null!; + private KeyCredentialServiceState m_service = null!; + private LinqApplicationsDatabase m_database = null!; + private NodeId m_ownerApplicationId; + private NodeId m_otherApplicationId; + + [OneTimeSetUp] + public async Task KeyCredentialSetUpAsync() + { + m_database = new LinqApplicationsDatabase(); + m_nodeManager = new TestableApplicationsNodeManager( + ReferenceServer.CurrentInstance, + ServerFixture.Config, + m_database, + Mock.Of()); + m_database.NamespaceIndex = m_nodeManager.ApplicationNamespaceIndex; + + ApplicationRecordDataType owner = CreateTestApplicationRecord("KeyCredentialOwner"); + ApplicationRecordDataType other = CreateTestApplicationRecord("KeyCredentialOther"); + m_ownerApplicationId = m_database.RegisterApplication(owner); + m_otherApplicationId = m_database.RegisterApplication(other); + + m_service = await m_nodeManager.CreateKeyCredentialServiceAsync().ConfigureAwait(false); + } + + [OneTimeTearDown] + public void KeyCredentialTearDown() + { + m_nodeManager?.Dispose(); + } + + [SetUp] + public void ResetStore() + { + m_nodeManager.KeyCredentialRequestStore = new InMemoryKeyCredentialRequestStore(); + } + + [Test] + public void KeyCredentialMethodsExposeApplicationPrivilegePermissions() + { + Assert.That(m_service.StartRequest!.OnReadRolePermissions, Is.Not.Null); + Assert.That(m_service.StartRequest.OnReadUserRolePermissions, Is.Not.Null); + Assert.That(m_service.FinishRequest!.OnReadRolePermissions, Is.Not.Null); + Assert.That(m_service.FinishRequest.OnReadUserRolePermissions, Is.Not.Null); + Assert.That(m_service.Revoke, Is.Not.Null); + Assert.That(m_service.Revoke!.OnReadRolePermissions, Is.Not.Null); + Assert.That(m_service.Revoke.OnReadUserRolePermissions, Is.Not.Null); + } + + [Test] + public async Task KeyCredentialAdminCanCompleteLifecycleAsync() + { + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 1); + + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult finished = + await FinishRequestAsync(context, requestId, cancelRequest: false) + .ConfigureAwait(false); + KeyCredentialRevokeMethodStateResult revoked = + await RevokeAsync(context, finished.CredentialId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(requestId.IsNull, Is.False); + Assert.That(ServiceResult.IsGood(finished.ServiceResult), Is.True); + Assert.That(finished.CredentialId, Is.Not.Empty); + Assert.That(finished.CredentialSecret.IsEmpty, Is.False); + Assert.That(ServiceResult.IsGood(revoked.ServiceResult), Is.True); + }); + } + + [Test] + public async Task ApplicationSelfAdminCanCompleteOwnApplicationLifecycleAsync() + { + ISystemContext context = CreateContext( + CreateRoleIdentity( + GdsRole.ApplicationSelfAdmin, + applicationId: m_ownerApplicationId), + certificateMarker: 2); + + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult finished = + await FinishRequestAsync(context, requestId, cancelRequest: false) + .ConfigureAwait(false); + KeyCredentialRevokeMethodStateResult revoked = + await RevokeAsync(context, finished.CredentialId).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(finished.ServiceResult), Is.True); + Assert.That(ServiceResult.IsGood(revoked.ServiceResult), Is.True); + } + + [Test] + public async Task ApplicationSelfAdminCannotManageAnotherApplicationAsync() + { + ISystemContext adminContext = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 3); + NodeId requestId = await StartRequestAsync(adminContext, OtherApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult finished = + await FinishRequestAsync(adminContext, requestId, cancelRequest: false) + .ConfigureAwait(false); + + ISystemContext selfAdminContext = CreateContext( + CreateRoleIdentity( + GdsRole.ApplicationSelfAdmin, + applicationId: m_ownerApplicationId), + certificateMarker: 3); + + AssertBadUserAccessDenied( + async () => await StartRequestAsync(selfAdminContext, OtherApplicationUri) + .ConfigureAwait(false)); + AssertBadUserAccessDenied( + async () => await FinishRequestAsync( + selfAdminContext, + requestId, + cancelRequest: false).ConfigureAwait(false)); + AssertBadUserAccessDenied( + async () => await RevokeAsync(selfAdminContext, finished.CredentialId) + .ConfigureAwait(false)); + } + + [Test] + public async Task ApplicationAdminCanCompleteManagedApplicationLifecycleAsync() + { + ISystemContext context = CreateContext( + CreateRoleIdentity( + GdsRole.ApplicationAdmin, + administeredApplicationIds: [m_ownerApplicationId]), + certificateMarker: 4); + + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult finished = + await FinishRequestAsync(context, requestId, cancelRequest: false) + .ConfigureAwait(false); + KeyCredentialRevokeMethodStateResult revoked = + await RevokeAsync(context, finished.CredentialId).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(finished.ServiceResult), Is.True); + Assert.That(ServiceResult.IsGood(revoked.ServiceResult), Is.True); + } + + [Test] + public async Task ApplicationAdminCannotManageUnmanagedApplicationAsync() + { + ISystemContext adminContext = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 5); + NodeId requestId = await StartRequestAsync(adminContext, OtherApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult finished = + await FinishRequestAsync(adminContext, requestId, cancelRequest: false) + .ConfigureAwait(false); + + ISystemContext applicationAdminContext = CreateContext( + CreateRoleIdentity( + GdsRole.ApplicationAdmin, + administeredApplicationIds: [m_ownerApplicationId]), + certificateMarker: 5); + + AssertBadUserAccessDenied( + async () => await StartRequestAsync( + applicationAdminContext, + OtherApplicationUri).ConfigureAwait(false)); + AssertBadUserAccessDenied( + async () => await FinishRequestAsync( + applicationAdminContext, + requestId, + cancelRequest: false).ConfigureAwait(false)); + AssertBadUserAccessDenied( + async () => await RevokeAsync( + applicationAdminContext, + finished.CredentialId).ConfigureAwait(false)); + } + + [Test] + public async Task FinishRequestWithDifferentInitiatingCertificateFailsAsync() + { + ISystemContext startContext = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 6); + NodeId requestId = await StartRequestAsync(startContext, OwnerApplicationUri) + .ConfigureAwait(false); + ISystemContext finishContext = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 7); + + Assert.That( + async () => await FinishRequestAsync( + finishContext, + requestId, + cancelRequest: false).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task CancelRequestReturnsGoodWithoutCredentialMaterialAsync() + { + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 8); + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + + KeyCredentialFinishRequestMethodStateResult result = + await FinishRequestAsync(context, requestId, cancelRequest: true) + .ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True); + Assert.That(result.CredentialId, Is.Null.Or.Empty); + Assert.That(result.CredentialSecret.IsEmpty, Is.True); + }); + } + + [Test] + public async Task PendingRequestReturnsBadRequestNotCompleteAsync() + { + m_nodeManager.KeyCredentialRequestStore = + new StateKeyCredentialRequestStore( + m_ownerApplicationId, + KeyCredentialRequestState.New); + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 9); + + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult result = + await FinishRequestAsync(context, requestId, cancelRequest: false) + .ConfigureAwait(false); + + Assert.That( + result.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadRequestNotComplete)); + Assert.That(result.CredentialSecret.IsEmpty, Is.True); + } + + [Test] + public async Task RejectedRequestReturnsBadRequestNotAllowedAsync() + { + m_nodeManager.KeyCredentialRequestStore = + new StateKeyCredentialRequestStore( + m_ownerApplicationId, + KeyCredentialRequestState.Rejected); + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 13); + + NodeId requestId = await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false); + KeyCredentialFinishRequestMethodStateResult result = + await FinishRequestAsync(context, requestId, cancelRequest: false) + .ConfigureAwait(false); + + Assert.That( + result.ServiceResult.StatusCode, + Is.EqualTo(StatusCodes.BadRequestNotAllowed)); + Assert.That(result.CredentialSecret.IsEmpty, Is.True); + } + + [Test] + public void UnknownApplicationFailureIsAuditedAndRethrown() + { + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 10); + + Assert.That( + async () => await StartRequestAsync(context, "urn:test:unknown") + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public void UnknownCredentialFailureIsAuditedAndRethrown() + { + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 11); + + Assert.That( + async () => await RevokeAsync(context, "unknown-credential") + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void StoreWithoutApplicationOwnershipIsRejected() + { + m_nodeManager.KeyCredentialRequestStore = new LegacyKeyCredentialRequestStore(); + ISystemContext context = CreateContext( + CreateRoleIdentity(GdsRole.KeyCredentialAdmin), + certificateMarker: 12); + + Assert.That( + async () => await StartRequestAsync(context, OwnerApplicationUri) + .ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + private async Task StartRequestAsync( + ISystemContext context, + string applicationUri) + { + KeyCredentialStartRequestMethodStateResult result = + await m_service.StartRequest!.OnCallAsync!( + context, + m_service.StartRequest, + m_service.NodeId, + applicationUri, + ByteString.From([1, 2, 3]), + SecurityPolicies.Basic256Sha256, + default, + CancellationToken.None).ConfigureAwait(false); + + Assert.That(ServiceResult.IsGood(result.ServiceResult), Is.True); + return result.RequestId; + } + + private ValueTask FinishRequestAsync( + ISystemContext context, + NodeId requestId, + bool cancelRequest) + { + return m_service.FinishRequest!.OnCallAsync!( + context, + m_service.FinishRequest, + m_service.NodeId, + requestId, + cancelRequest, + CancellationToken.None); + } + + private ValueTask RevokeAsync( + ISystemContext context, + string credentialId) + { + return m_service.Revoke!.OnCallAsync!( + context, + m_service.Revoke, + m_service.NodeId, + credentialId, + CancellationToken.None); + } + + private ServerSystemContext CreateContext(IUserIdentity identity, byte certificateMarker) + { + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt, + SecurityPolicyUri = SecurityPolicies.Basic256Sha256 + }; + var channelContext = new SecureChannelContext( + $"key-credential-{certificateMarker}", + endpoint, + RequestEncoding.Binary, + [certificateMarker, 1, 2, 3]); + var operationContext = new OperationContext( + new RequestHeader(), + channelContext, + RequestType.Call, + RequestLifetime.None, + identity); + return ReferenceServer.CurrentInstance.DefaultSystemContext.Copy(operationContext); + } + + private GdsRoleBasedIdentity CreateRoleIdentity( + Role role, + NodeId applicationId = default, + IEnumerable? administeredApplicationIds = null) + { + return new GdsRoleBasedIdentity( + new UserIdentity(role.Name, s_passwordBytes), + [role], + applicationId, + administeredApplicationIds, + ReferenceServer.CurrentInstance.NamespaceUris); + } + + private static void AssertBadUserAccessDenied(Func action) + { + Assert.That( + action, + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadUserAccessDenied)); + } + + private sealed class TestableApplicationsNodeManager : ApplicationsNodeManager + { + public TestableApplicationsNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + LinqApplicationsDatabase database, + ICertificateGroup certificateGroup) + : base( + server, + configuration, + database, + database, + certificateGroup, + autoApprove: true) + { + } + + public ushort ApplicationNamespaceIndex => NamespaceIndexes[0]; + + public async ValueTask CreateKeyCredentialServiceAsync() + { + ushort namespaceIndex = NamespaceIndexes[1]; + var service = new KeyCredentialServiceState(null); + service.Create( + SystemContext, + new NodeId("KeyCredentialManagement/Test", namespaceIndex), + new QualifiedName("Test", namespaceIndex), + new LocalizedText("Test"), + false); + service.AddRevoke(SystemContext); + + NodeState active = await AddBehaviourToPredefinedNodeAsync( + SystemContext, + service).ConfigureAwait(false); + return (KeyCredentialServiceState)active; + } + } + + private sealed class StateKeyCredentialRequestStore : + IApplicationOwnedKeyCredentialRequestStore + { + private readonly NodeId m_applicationId; + private readonly KeyCredentialRequestState m_state; + private readonly NodeId m_requestId = new(1); + + public StateKeyCredentialRequestStore( + NodeId applicationId, + KeyCredentialRequestState state) + { + m_applicationId = applicationId; + m_state = state; + } + + public ValueTask StartRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + CancellationToken cancellationToken = default) + { + return new ValueTask(m_requestId); + } + + public ValueTask StartBoundRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + return new ValueTask(m_requestId); + } + + public ValueTask StartOwnedBoundRequestAsync( + string applicationUri, + NodeId applicationId, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + return new ValueTask(m_requestId); + } + + public ValueTask FinishRequestAsync( + NodeId requestId, + bool cancelRequest, + CancellationToken cancellationToken = default) + { + return new ValueTask( + new FinishKeyCredentialRequestResult + { + State = m_state + }); + } + + public ValueTask FinishBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + return FinishRequestAsync(requestId, cancelRequest, cancellationToken); + } + + public ValueTask GetRequestApplicationIdAsync( + NodeId requestId, + CancellationToken cancellationToken = default) + { + return new ValueTask(m_applicationId); + } + + public ValueTask FinishOwnedBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + NodeId applicationId, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + return FinishRequestAsync(requestId, cancelRequest, cancellationToken); + } + + public ValueTask GetCredentialApplicationIdAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + return new ValueTask(m_applicationId); + } + + public ValueTask RevokeAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + return default; + } + + public ValueTask RevokeOwnedAsync( + string credentialId, + NodeId applicationId, + CancellationToken cancellationToken = default) + { + return default; + } + } + + private sealed class LegacyKeyCredentialRequestStore : IKeyCredentialRequestStore + { + public ValueTask StartRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + CancellationToken cancellationToken = default) + { + return new ValueTask(new NodeId(1)); + } + + public ValueTask FinishRequestAsync( + NodeId requestId, + bool cancelRequest, + CancellationToken cancellationToken = default) + { + return new ValueTask( + new FinishKeyCredentialRequestResult + { + State = KeyCredentialRequestState.Completed + }); + } + + public ValueTask RevokeAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + return default; + } + } + } +} diff --git a/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs b/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs index 022a0d0f1b..820a18e93a 100644 --- a/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs +++ b/tests/Opc.Ua.Gds.Tests/AuditEventsTests.cs @@ -90,6 +90,102 @@ [new NodeId(1), false], Assert.That(auditEvent.Message.Value.Text, Is.EqualTo("KeyCredentialDeliveredAuditEvent failed.")); } + [Test] + public void KeyCredentialRequestedFailureAuditRedactsExceptionDetails() + { + const string secret = "request-secret-must-not-appear"; + var auditServer = new CapturingAuditEventServer(); + ILogger logger = NUnitTelemetryContext.Create().CreateLogger(); + + auditServer.ReportKeyCredentialRequestedAuditEvent( + auditServer.DefaultAuditContext, + Ua.ObjectIds.Server, + new MethodState(null), + ["urn:test:app", ByteString.From([1, 2, 3])], + logger, + new InvalidOperationException($"Store failed with {secret}.")); + + AssertFailureAudit( + auditServer, + "KeyCredentialRequestedAuditEvent failed.", + secret); + } + + [Test] + public void KeyCredentialRevokedFailureAuditRedactsExceptionDetails() + { + const string secret = "revoke-secret-must-not-appear"; + var auditServer = new CapturingAuditEventServer(); + ILogger logger = NUnitTelemetryContext.Create().CreateLogger(); + + auditServer.ReportKeyCredentialRevokedAuditEvent( + auditServer.DefaultAuditContext, + Ua.ObjectIds.Server, + new MethodState(null), + ["KC-1"], + logger, + new InvalidOperationException($"Store failed with {secret}.")); + + AssertFailureAudit( + auditServer, + "KeyCredentialRevokedAuditEvent failed.", + secret); + } + + [Test] + public void AccessTokenFailureAuditIncludesExceptionDetails() + { + const string detail = "token-provider-diagnostic"; + var auditServer = new CapturingAuditEventServer(); + ILogger logger = NUnitTelemetryContext.Create().CreateLogger(); + + auditServer.ReportAccessTokenIssuedAuditEvent( + auditServer.DefaultAuditContext, + Ua.ObjectIds.Server, + new MethodState(null), + ["resource"], + logger, + new InvalidOperationException(detail)); + + Assert.That(auditServer.Events, Has.Count.EqualTo(1)); + AuditUpdateMethodEventState auditEvent = auditServer.Events[0]; + Assert.That(auditEvent.Status!.Value, Is.False); + Assert.That(auditEvent.Message!.Value.Text, Does.Contain(detail)); + } + + [Test] + public void KeyCredentialSuccessAuditReportsGoodStatus() + { + var auditServer = new CapturingAuditEventServer(); + ILogger logger = NUnitTelemetryContext.Create().CreateLogger(); + + auditServer.ReportKeyCredentialDeliveredAuditEvent( + auditServer.DefaultAuditContext, + Ua.ObjectIds.Server, + new MethodState(null), + [new NodeId(1), false], + logger); + + Assert.That(auditServer.Events, Has.Count.EqualTo(1)); + AuditUpdateMethodEventState auditEvent = auditServer.Events[0]; + Assert.That(auditEvent.Status!.Value, Is.True); + Assert.That( + auditEvent.Message!.Value.Text, + Is.EqualTo("KeyCredentialDeliveredAuditEvent.")); + } + + private static void AssertFailureAudit( + CapturingAuditEventServer auditServer, + string expectedMessage, + string secret) + { + Assert.That(auditServer.Events, Has.Count.EqualTo(1)); + AuditUpdateMethodEventState auditEvent = auditServer.Events[0]; + Assert.That(auditEvent.Status!.Value, Is.False); + Assert.That(auditEvent.Message!.Value.Text, Is.EqualTo(expectedMessage)); + Assert.That(auditEvent.Message.Value.Text, Does.Not.Contain(secret)); + } + private sealed class CapturingAuditEventServer : IAuditEventServer { public CapturingAuditEventServer() diff --git a/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs b/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs index c71dda20a2..0253e7819e 100644 --- a/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs +++ b/tests/Opc.Ua.Gds.Tests/AuthorizationHelperTests.cs @@ -430,7 +430,7 @@ public void GetClientCertificateFingerprintReturnsSha256() byte[] expected = SHA256.HashData(clientCertificate); #else byte[] expected; - using (SHA256 sha256 = SHA256.Create()) + using (var sha256 = SHA256.Create()) { expected = sha256.ComputeHash(clientCertificate); } @@ -469,6 +469,35 @@ public void GetClientCertificateFingerprintThrowsWhenCertificateIsMissing() .EqualTo(StatusCodes.BadSecurityChecksFailed)); } + [Test] + public void GetClientCertificateFingerprintThrowsWhenCertificateIsEmpty() + { + var endpoint = new EndpointDescription + { + SecurityMode = MessageSecurityMode.SignAndEncrypt + }; + var channelContext = new SecureChannelContext( + "test-channel", + endpoint, + RequestEncoding.Binary, + []); + var operationContext = new OperationContext( + new RequestHeader(), + channelContext, + RequestType.Call, + RequestLifetime.None); + var context = new SystemContext(operationContext, m_telemetry) + { + NamespaceUris = m_namespaceTable + }; + + Assert.That( + () => AuthorizationHelper.GetClientCertificateFingerprint(context), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + [Test] public void StaticRoleListsAreCorrectlyPopulated() { diff --git a/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs b/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs index 4abf72d2aa..3a30801ded 100644 --- a/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs +++ b/tests/Opc.Ua.Gds.Tests/KeyCredentialConformanceTests.cs @@ -81,6 +81,97 @@ public void UnknownApplicationUriReturnsBadNotFound() .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(MockBehavior.Strict); + + Assert.That( + () => ApplicationsNodeManager.ResolveKeyCredentialApplicationId( + database.Object, + applicationUri!), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public void NullApplicationSearchResultReturnsBadNotFound() + { + var database = new Mock(MockBehavior.Strict); + database + .Setup(db => db.FindApplications(ApplicationUri)) + .Returns((ApplicationRecordDataType[]?)null); + + Assert.That( + () => ApplicationsNodeManager.ResolveKeyCredentialApplicationId( + database.Object, + ApplicationUri), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public void NonExactApplicationUriMatchReturnsBadNotFound() + { + var database = new Mock(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() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadNotFound)); + } + + [Test] + public void NullApplicationIdReturnsBadConfigurationError() + { + var database = new Mock(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() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadConfigurationError)); + } + [Test] public void DuplicateApplicationUriReturnsBadConfigurationError() { @@ -121,7 +212,9 @@ public void PendingFinishReturnsBadRequestNotComplete() }; KeyCredentialFinishRequestMethodStateResult result = - ApplicationsNodeManager.CreateKeyCredentialFinishResult(finished); + ApplicationsNodeManager.CreateKeyCredentialFinishResult( + finished, + cancelRequest: false); Assert.That(result.ServiceResult.StatusCode, Is.EqualTo(StatusCodes.BadRequestNotComplete)); Assert.That(result.CredentialId, Is.Null.Or.Empty); @@ -129,12 +222,79 @@ public void PendingFinishReturnsBadRequestNotComplete() } [Test] - public void CompletedFinishReturnsGood() + public void CompletedFinishReturnsAllCredentialOutputs() { - ServiceResult result = ApplicationsNodeManager.GetKeyCredentialFinishServiceResult( - KeyCredentialRequestState.Completed); + ArrayOf 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); + } } } diff --git a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs index 39b3040bf1..207d3ff179 100644 --- a/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs +++ b/tests/Opc.Ua.Gds.Tests/KeyCredentialRequestStoreTests.cs @@ -27,6 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ +using System; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; @@ -285,6 +286,251 @@ public void BindingCompatibilityRejectsLegacyStore() .EqualTo(StatusCodes.BadSecurityChecksFailed)); } + [Test] + public void FinishBindingCompatibilityRejectsLegacyStore() + { + IKeyCredentialRequestStore store = new LegacyKeyCredentialRequestStore(); + + Assert.That( + async () => await store.FinishBoundRequestCompatAsync( + new NodeId(1), + cancelRequest: false, + CreateFingerprint(1), + CancellationToken.None).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task BindingCompatibilityDispatchesToBoundStoreAsync() + { + var boundStore = new RecordingBoundKeyCredentialRequestStore(); + IKeyCredentialRequestStore store = boundStore; + ByteString fingerprint = CreateFingerprint(1); + + NodeId requestId = await store.StartBoundRequestCompatAsync( + "urn:test:app", + default, + null, + default, + fingerprint, + CancellationToken.None).ConfigureAwait(false); + FinishKeyCredentialRequestResult result = + await store.FinishBoundRequestCompatAsync( + requestId, + cancelRequest: false, + fingerprint, + CancellationToken.None).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(requestId, Is.EqualTo(new NodeId(2))); + Assert.That(result.State, Is.EqualTo(KeyCredentialRequestState.Completed)); + Assert.That(boundStore.StartBoundCalled, Is.True); + Assert.That(boundStore.FinishBoundCalled, Is.True); + }); + } + + [Test] + public void ConstructorWithNullSecretStoreThrows() + { + Assert.That( + () => new InMemoryKeyCredentialRequestStore(null!), + Throws.ArgumentNullException); + } + + [Test] + public void StartOwnedBoundRequestWithNullApplicationIdThrows() + { + Assert.That( + async () => await m_store.StartOwnedBoundRequestAsync( + "urn:test:app", + NodeId.Null, + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [TestCase(0)] + [TestCase(31)] + [TestCase(33)] + public void StartBoundRequestWithInvalidFingerprintLengthThrows(int length) + { + Assert.That( + async () => await m_store.StartBoundRequestAsync( + "urn:test:app", + default, + null, + default, + ByteString.From(new byte[length])).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task FinishBoundRequestWithInvalidFingerprintLengthThrowsAsync() + { + NodeId requestId = await m_store.StartBoundRequestAsync( + "urn:test:app", + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishBoundRequestAsync( + requestId, + cancelRequest: false, + ByteString.From(new byte[31])).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task OwnedOperationsWithNullApplicationIdThrowAsync() + { + NodeId requestId = await m_store.StartOwnedBoundRequestAsync( + "urn:test:app", + new NodeId(42), + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + FinishKeyCredentialRequestResult finished = + await m_store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest: false, + new NodeId(42), + CreateFingerprint(1)).ConfigureAwait(false); + + Assert.That( + async () => await m_store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest: false, + NodeId.Null, + CreateFingerprint(1)).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + Assert.That( + async () => await m_store.RevokeOwnedAsync( + finished.CredentialId!, + NodeId.Null).ConfigureAwait(false), + Throws.TypeOf() + .With.Property(nameof(ServiceResultException.StatusCode)) + .EqualTo(StatusCodes.BadSecurityChecksFailed)); + } + + [Test] + public async Task OwnershipLookupsHonorCancellationAsync() + { + var applicationId = new NodeId(42); + NodeId requestId = await m_store.StartOwnedBoundRequestAsync( + "urn:test:app", + applicationId, + default, + null, + default, + CreateFingerprint(1)).ConfigureAwait(false); + FinishKeyCredentialRequestResult finished = + await m_store.FinishOwnedBoundRequestAsync( + requestId, + cancelRequest: false, + applicationId, + CreateFingerprint(1)).ConfigureAwait(false); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.That( + async () => await m_store.GetRequestApplicationIdAsync( + requestId, + cts.Token).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That( + async () => await m_store.GetCredentialApplicationIdAsync( + finished.CredentialId!, + cts.Token).ConfigureAwait(false), + Throws.InstanceOf()); + } + + [Test] + public async Task RepeatedFinishPreservesCompletedStateAndSecretAsync() + { + NodeId requestId = await m_store.StartRequestAsync( + "urn:test:app", + default, + null, + default).ConfigureAwait(false); + FinishKeyCredentialRequestResult first = + await m_store.FinishRequestAsync( + requestId, + cancelRequest: false).ConfigureAwait(false); + + FinishKeyCredentialRequestResult second = + await m_store.FinishRequestAsync( + requestId, + cancelRequest: false).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(second.State, Is.EqualTo(KeyCredentialRequestState.Completed)); + Assert.That(second.CredentialId, Is.EqualTo(first.CredentialId)); + Assert.That(second.CredentialSecret.ToArray(), + Is.EqualTo(first.CredentialSecret.ToArray())); + }); + } + + [Test] + public async Task FinishAfterCancelDoesNotReturnPurgedSecretAsync() + { + NodeId requestId = await m_store.StartRequestAsync( + "urn:test:app", + default, + null, + default).ConfigureAwait(false); + await m_store.FinishRequestAsync( + requestId, + cancelRequest: true).ConfigureAwait(false); + + FinishKeyCredentialRequestResult result = + await m_store.FinishRequestAsync( + requestId, + cancelRequest: false).ConfigureAwait(false); + + Assert.That(result.State, Is.EqualTo(KeyCredentialRequestState.Rejected)); + Assert.That(result.CredentialSecret.IsEmpty, Is.True); + } + + [Test] + public async Task FinishAfterRevokeDoesNotReturnPurgedSecretAsync() + { + NodeId requestId = await m_store.StartRequestAsync( + "urn:test:app", + default, + null, + default).ConfigureAwait(false); + FinishKeyCredentialRequestResult issued = + await m_store.FinishRequestAsync( + requestId, + cancelRequest: false).ConfigureAwait(false); + await m_store.RevokeAsync(issued.CredentialId!).ConfigureAwait(false); + + FinishKeyCredentialRequestResult result = + await m_store.FinishRequestAsync( + requestId, + cancelRequest: false).ConfigureAwait(false); + + Assert.That(result.State, Is.EqualTo(KeyCredentialRequestState.Rejected)); + Assert.That(result.CredentialSecret.IsEmpty, Is.True); + } + [Test] public async Task FinishRequestWithCancelRejectsRequestAsync() { @@ -388,5 +634,63 @@ public ValueTask RevokeAsync( return default; } } + + private sealed class RecordingBoundKeyCredentialRequestStore : + IClientCertificateBoundKeyCredentialRequestStore + { + public bool StartBoundCalled { get; private set; } + public bool FinishBoundCalled { get; private set; } + + public ValueTask StartRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + CancellationToken cancellationToken = default) + { + return new ValueTask(new NodeId(1)); + } + + public ValueTask StartBoundRequestAsync( + string applicationUri, + ByteString publicKey, + string? securityPolicyUri, + ArrayOf requestedRoles, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + StartBoundCalled = true; + return new ValueTask(new NodeId(2)); + } + + public ValueTask FinishRequestAsync( + NodeId requestId, + bool cancelRequest, + CancellationToken cancellationToken = default) + { + return new ValueTask( + new FinishKeyCredentialRequestResult + { + State = KeyCredentialRequestState.Completed + }); + } + + public ValueTask FinishBoundRequestAsync( + NodeId requestId, + bool cancelRequest, + ByteString clientCertificateFingerprint, + CancellationToken cancellationToken = default) + { + FinishBoundCalled = true; + return FinishRequestAsync(requestId, cancelRequest, cancellationToken); + } + + public ValueTask RevokeAsync( + string credentialId, + CancellationToken cancellationToken = default) + { + return default; + } + } } }