Files
UA-.NETStandard/Libraries/Opc.Ua.Gds.Server.Common/IKeyCredentialRequestStore.cs
Marc Schier e1b9cea3ea Reduce sync-over-async in non-obsolete server APIs + post-merge code style cleanup (#3806)
## Proposed changes

Reduces sync-over-async in non-obsolete server APIs, removes stale
`#region/#endregion` noise from the test suite, and fixes analyzer/test
fallout from merging the Part 11 Historical Access framework (#3797).
Follow-up review feedback is included: `FileSystemNodeManager` is now
based on `AsyncCustomNodeManager`.

### Sync-over-async reduction (~24 `GetAwaiter().GetResult()` sites)

**Server-side:**
- `TrustList`: async `OnCallAsync` handlers added; sync `OnCall`
handlers retained as compat shims so legacy `CustomNodeManager2`
subclasses that host the TrustList but do not implement
`ICallAsyncNodeManager` keep working through `MethodState.Call`.
- `GDS ApplicationsNodeManager`: `RequestAccessToken`,
`KeyCredentialStart/FinishRequest`, and `Revoke` moved to `OnCallAsync`.
- `IKeyCredentialRequestStore`: interface + in-memory implementation
converted to async; `FinishKeyCredentialRequestResult` record carries
the previous out-set. Tests in `Tests/Opc.Ua.Gds.Tests/` updated to the
new async API.
- `FileSystem`:
- `DirectoryObjectState` methods (`CreateDirectory` / `CreateFile` /
`Delete` / `MoveOrCopy`) use `OnCallAsync`.
- `FileSystemNodeManager` now derives from `AsyncCustomNodeManager` and
overrides async address-space/node-resolution paths
(`CreateAddressSpaceAsync`, `DeleteAddressSpaceAsync`,
`GetManagerHandleAsync`, `ValidateNodeAsync`) to avoid sync-over-async
in dynamic node validation.
- `FileSystemNodeManagerFactory` now supports async creation
(`IAsyncNodeManagerFactory`) while retaining sync factory compatibility.
- Reference server registration of the FileSystem node manager is
aligned to the async node manager list.
- `ConfigurationNodeManager` namespace lookup uses sync
`GetManagerHandle` to keep the remaining `GetResult` inside the
documented `SyncNodeManagerAdapter` bridge.
- `ServerBase`: new `OnServerStartingAsync`; `StartAsync` now awaits it.
- `StandardServer.CreateMasterNodeManagerAsync` added; sync wrapper kept
for backward compatibility.
- `IServerInternal.CloseSession` marked `[Obsolete]`
(`CloseSessionAsync` exists).
- `IMasterNodeManager.RemoveReferences` marked `[Obsolete]`; in-tree
sync callers remain with scoped `CS0618` pragmas + TODO.

**Stack:**
- `EncryptedSecret`: added `VerifyHeaderForEccAsync` / `DecryptAsync` /
`TryDecryptAsync`; `UserNameIdentityTokenHandler.DecryptAsync` awaits
ECC validation.
- `CertificatePasswordProvider` ctor: `GetResult` fallback replaced with
explicit `InvalidOperationException`.

### Code-style and post-merge cleanup

- Removed unused `#region`/`#endregion` directives across test files.
- Restored green build state after Part 11 merge fallout fixes
(production + analyzer + test cleanup from earlier commits in this PR).

### CI / validation notes

- This PR branch surfaced runtime regressions introduced by Part 11
after the build-breaker was fixed (outside scope of this
async/code-style cleanup).
- Follow-up feedback change validation for `FileSystemNodeManager`:
- `dotnet build Libraries/Opc.Ua.Server/Opc.Ua.Server.csproj -f net10.0
--no-restore` ✅
- `dotnet test Tests/Opc.Ua.Server.Tests/Opc.Ua.Server.Tests.csproj -f
net10.0 --filter FileSystemNodeIdTests` ✅
- `dotnet test Tests/Opc.Ua.History.Tests/Opc.Ua.History.Tests.csproj -f
net10.0 --filter FileSystemSmokeTests` ✅ (test skipped in environment)

## Related Issues

- None.

## Types of changes

- [ ] Bugfix (non-breaking change which fixes an issue)
- [x] Enhancement (non-breaking change which adds functionality)
- [x] Test enhancement (non-breaking change to increase test coverage)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected, requires version increase of
Nuget packages)
- [ ] Documentation Update (if none of the other choices apply)

## Checklist

- [x] I have read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf).
- [x] I ran tests locally with my changes, all passed.
- [ ] I fixed all failing tests in the CI pipelines.
- [ ] I fixed all introduced issues with CodeQL and LGTM.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added necessary documentation (if appropriate).
- [x] Any dependent changes have been merged and published in downstream
modules.
2026-05-27 06:48:30 +02:00

313 lines
13 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
namespace Opc.Ua.Gds.Server
{
/// <summary>
/// Represents the state of a key-credential request.
/// </summary>
public enum KeyCredentialRequestState
{
/// <summary>Request is new / pending approval.</summary>
New,
/// <summary>Request has been approved.</summary>
Approved,
/// <summary>Request has been rejected.</summary>
Rejected,
/// <summary>Request has been completed.</summary>
Completed
}
/// <summary>
/// Represents a single key-credential request record.
/// </summary>
public sealed class KeyCredentialRequestRecord
{
/// <summary>The unique request identifier.</summary>
public NodeId RequestId { get; set; }
/// <summary>The ApplicationUri that requested the credential.</summary>
public string? ApplicationUri { get; set; }
/// <summary>The public key supplied by the requester.</summary>
public ByteString PublicKey { get; set; }
/// <summary>The security policy URI for the credential.</summary>
public string? SecurityPolicyUri { get; set; }
/// <summary>The roles requested by the caller.</summary>
public ArrayOf<NodeId> RequestedRoles { get; set; }
/// <summary>Current state of the request.</summary>
public KeyCredentialRequestState State { get; set; }
/// <summary>The credential identifier (populated after approval).</summary>
public string? CredentialId { get; set; }
/// <summary>The credential secret (populated after approval).</summary>
public ByteString CredentialSecret { get; set; }
/// <summary>Thumbprint of the certificate that protects the credential.</summary>
public string? CertificateThumbprint { get; set; }
/// <summary>The security policy URI actually assigned.</summary>
public string? GrantedSecurityPolicyUri { get; set; }
/// <summary>The roles actually granted.</summary>
public ArrayOf<NodeId> GrantedRoles { get; set; }
/// <summary>When the request was created.</summary>
public DateTime CreatedAt { get; set; }
}
/// <summary>
/// Result of <see cref="IKeyCredentialRequestStore.FinishRequestAsync"/>.
/// </summary>
public readonly record struct FinishKeyCredentialRequestResult
{
/// <summary>The state of the request.</summary>
public KeyCredentialRequestState State { get; init; }
/// <summary>The issued credential id, or <c>null</c> when cancelled.</summary>
public string? CredentialId { get; init; }
/// <summary>The issued credential secret, or <c>default</c> when cancelled.</summary>
public ByteString CredentialSecret { get; init; }
/// <summary>The thumbprint of the protecting certificate.</summary>
public string? CertificateThumbprint { get; init; }
/// <summary>The assigned security policy URI.</summary>
public string? SecurityPolicyUri { get; init; }
/// <summary>The granted roles.</summary>
public ArrayOf<NodeId> GrantedRoles { get; init; }
}
/// <summary>
/// An abstract interface to the key-credential request store.
/// Mirrors <see cref="ICertificateRequest"/> but for OPC 10000-12
/// §8 KeyCredentialService requests.
/// </summary>
public interface IKeyCredentialRequestStore
{
/// <summary>
/// Start a new key-credential request.
/// </summary>
ValueTask<NodeId> StartRequestAsync(
string applicationUri,
ByteString publicKey,
string? securityPolicyUri,
ArrayOf<NodeId> requestedRoles,
CancellationToken cancellationToken = default);
/// <summary>
/// Finish (approve or cancel) a key-credential request.
/// </summary>
/// <param name="requestId">The request to finish.</param>
/// <param name="cancelRequest">
/// When <c>true</c> the request is cancelled; when <c>false</c>
/// the credential is issued.
/// </param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The state of the request and any issued credential data.</returns>
ValueTask<FinishKeyCredentialRequestResult> FinishRequestAsync(
NodeId requestId,
bool cancelRequest,
CancellationToken cancellationToken = default);
/// <summary>
/// Revoke a previously issued credential.
/// </summary>
/// <param name="credentialId">The credential to revoke.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default);
}
/// <summary>
/// In-memory implementation of <see cref="IKeyCredentialRequestStore"/>.
/// Suitable for testing and single-process GDS deployments.
/// </summary>
/// <remarks>
/// When an <see cref="ISecretStore"/> is supplied, credential secrets
/// are persisted through the store rather than held in-process. This
/// allows a production deployment to plug in Key Vault, DPAPI,
/// Kubernetes secrets, or any other backend that implements
/// <see cref="ISecretStore"/> without changing the GDS code.
/// </remarks>
public sealed class InMemoryKeyCredentialRequestStore : IKeyCredentialRequestStore
{
private int m_nextId;
private readonly ConcurrentDictionary<NodeId, KeyCredentialRequestRecord> m_requests = new();
private readonly ConcurrentDictionary<string, KeyCredentialRequestRecord> m_credentials = new(StringComparer.Ordinal);
private readonly ISecretStore m_secretStore;
/// <summary>
/// Creates a store that holds credential secrets in-process.
/// </summary>
public InMemoryKeyCredentialRequestStore()
: this(new InMemorySecretStore("KeyCredential"))
{
}
/// <summary>
/// Creates a store that delegates credential-secret persistence
/// to the supplied <paramref name="secretStore"/>.
/// </summary>
/// <param name="secretStore">
/// The secret store used to persist, materialise and purge
/// credential secrets. Pass an <see cref="InMemorySecretStore"/>
/// for testing or a Key Vault / DPAPI store for production.
/// </param>
public InMemoryKeyCredentialRequestStore(ISecretStore secretStore)
{
m_secretStore = secretStore ?? throw new ArgumentNullException(nameof(secretStore));
}
/// <inheritdoc/>
public async ValueTask<NodeId> StartRequestAsync(
string applicationUri,
ByteString publicKey,
string? securityPolicyUri,
ArrayOf<NodeId> requestedRoles,
CancellationToken cancellationToken = default)
{
int id = Interlocked.Increment(ref m_nextId);
var requestId = new NodeId((uint)id);
string credentialId = $"KC-{id}";
// generate and persist the credential secret via ISecretStore
byte[] secretBytes = GenerateRandomBytes(32);
var secretId = new SecretIdentifier(credentialId, m_secretStore.StoreType);
await m_secretStore.SetAsync(secretId, secretBytes, cancellationToken).ConfigureAwait(false);
var record = new KeyCredentialRequestRecord
{
RequestId = requestId,
ApplicationUri = applicationUri,
PublicKey = publicKey,
SecurityPolicyUri = securityPolicyUri,
RequestedRoles = requestedRoles,
State = KeyCredentialRequestState.Approved,
CreatedAt = DateTime.UtcNow,
CredentialId = credentialId,
CredentialSecret = ByteString.From(secretBytes),
CertificateThumbprint = null,
GrantedSecurityPolicyUri = securityPolicyUri,
GrantedRoles = requestedRoles
};
m_requests[requestId] = record;
m_credentials[record.CredentialId] = record;
return requestId;
}
/// <inheritdoc/>
public async ValueTask<FinishKeyCredentialRequestResult> FinishRequestAsync(
NodeId requestId,
bool cancelRequest,
CancellationToken cancellationToken = default)
{
if (!m_requests.TryGetValue(requestId, out KeyCredentialRequestRecord? record))
{
throw new ServiceResultException(
StatusCodes.BadNotFound,
"The RequestId is not known.");
}
if (cancelRequest)
{
record.State = KeyCredentialRequestState.Rejected;
// purge the secret on cancel
if (record.CredentialId != null)
{
var secretId = new SecretIdentifier(record.CredentialId, m_secretStore.StoreType);
await m_secretStore.RemoveAsync(secretId, cancellationToken).ConfigureAwait(false);
}
return new FinishKeyCredentialRequestResult { State = record.State };
}
if (record.State == KeyCredentialRequestState.Approved)
{
record.State = KeyCredentialRequestState.Completed;
}
// materialise the secret from the store
string? credentialId = record.CredentialId;
var sid = new SecretIdentifier(credentialId!, m_secretStore.StoreType);
using ISecret? secret = m_secretStore.TryGet(sid);
ByteString credentialSecret = secret != null
? ByteString.From(secret.Bytes.ToArray())
: record.CredentialSecret;
return new FinishKeyCredentialRequestResult
{
State = record.State,
CredentialId = credentialId,
CredentialSecret = credentialSecret,
CertificateThumbprint = record.CertificateThumbprint,
SecurityPolicyUri = record.GrantedSecurityPolicyUri,
GrantedRoles = record.GrantedRoles
};
}
/// <inheritdoc/>
public async 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.");
}
record.State = KeyCredentialRequestState.Rejected;
// purge the secret from the backing store
var secretId = new SecretIdentifier(credentialId, m_secretStore.StoreType);
await m_secretStore.RemoveAsync(secretId, cancellationToken).ConfigureAwait(false);
}
private static byte[] GenerateRandomBytes(int length)
{
byte[] buffer = new byte[length];
using var rng = System.Security.Cryptography.RandomNumberGenerator.Create();
rng.GetBytes(buffer);
return buffer;
}
}
}