/* ========================================================================
* 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
{
///
/// Represents the state of a key-credential request.
///
public enum KeyCredentialRequestState
{
///
/// Request is new / pending approval.
///
New,
///
/// Request has been approved.
///
Approved,
///
/// Request has been rejected.
///
Rejected,
///
/// Request has been completed.
///
Completed
}
///
/// Represents a single key-credential request record.
///
public sealed class KeyCredentialRequestRecord
{
///
/// The unique request identifier.
///
public NodeId RequestId { get; set; }
///
/// The ApplicationUri that requested the credential.
///
public string? ApplicationUri { get; set; }
///
/// The public key supplied by the requester.
///
public ByteString PublicKey { get; set; }
///
/// The security policy URI for the credential.
///
public string? SecurityPolicyUri { get; set; }
///
/// The roles requested by the caller.
///
public ArrayOf RequestedRoles { get; set; }
///
/// Current state of the request.
///
public KeyCredentialRequestState State { get; set; }
///
/// The credential identifier (populated after approval).
///
public string? CredentialId { get; set; }
///
/// The credential secret (populated after approval).
///
public ByteString CredentialSecret { get; set; }
///
/// Thumbprint of the certificate that protects the credential.
///
public string? CertificateThumbprint { get; set; }
///
/// The security policy URI actually assigned.
///
public string? GrantedSecurityPolicyUri { get; set; }
///
/// The roles actually granted.
///
public ArrayOf GrantedRoles { get; set; }
///
/// When the request was created.
///
public DateTime CreatedAt { get; set; }
}
///
/// Result of .
///
public readonly record struct FinishKeyCredentialRequestResult
{
///
/// The state of the request.
///
public KeyCredentialRequestState State { get; init; }
/// The issued credential id, or null when cancelled.
public string? CredentialId { get; init; }
/// The issued credential secret, or default when cancelled.
public ByteString CredentialSecret { get; init; }
///
/// The thumbprint of the protecting certificate.
///
public string? CertificateThumbprint { get; init; }
///
/// The assigned security policy URI.
///
public string? SecurityPolicyUri { get; init; }
///
/// The granted roles.
///
public ArrayOf GrantedRoles { get; init; }
}
///
/// An abstract interface to the key-credential request store.
/// Mirrors but for OPC 10000-12
/// ยง8 KeyCredentialService requests.
///
public interface IKeyCredentialRequestStore
{
///
/// Start a new key-credential request.
///
ValueTask StartRequestAsync(
string applicationUri,
ByteString publicKey,
string? securityPolicyUri,
ArrayOf requestedRoles,
CancellationToken cancellationToken = default);
///
/// Finish (approve or cancel) a key-credential request.
///
/// The request to finish.
///
/// When true the request is cancelled; when false
/// the credential is issued.
///
/// The cancellation token.
/// The state of the request and any issued credential data.
ValueTask FinishRequestAsync(
NodeId requestId,
bool cancelRequest,
CancellationToken cancellationToken = default);
///
/// Revoke a previously issued credential.
///
/// The credential to revoke.
/// The cancellation token.
ValueTask RevokeAsync(string credentialId, CancellationToken cancellationToken = default);
}
///
/// In-memory implementation of .
/// Suitable for testing and single-process GDS deployments.
///
///
/// When an 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
/// without changing the GDS code.
///
public sealed class InMemoryKeyCredentialRequestStore : IKeyCredentialRequestStore
{
private int m_nextId;
private readonly ConcurrentDictionary m_requests = new();
private readonly ConcurrentDictionary m_credentials = new(StringComparer.Ordinal);
private readonly ISecretStore m_secretStore;
///
/// Creates a store that holds credential secrets in-process.
///
public InMemoryKeyCredentialRequestStore()
: this(new InMemorySecretStore("KeyCredential"))
{
}
///
/// Creates a store that delegates credential-secret persistence
/// to the supplied .
///
///
/// The secret store used to persist, materialise and purge
/// credential secrets. Pass an
/// for testing or a Key Vault / DPAPI store for production.
///
public InMemoryKeyCredentialRequestStore(ISecretStore secretStore)
{
m_secretStore = secretStore ?? throw new ArgumentNullException(nameof(secretStore));
}
///
public async ValueTask StartRequestAsync(
string applicationUri,
ByteString publicKey,
string? securityPolicyUri,
ArrayOf 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;
}
///
public async ValueTask 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
};
}
///
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;
}
}
}