/* ======================================================================== * 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; } } }