/* ======================================================================== * Copyright (c) 2005-2025 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.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using Opc.Ua.Security.Certificates; namespace Opc.Ua.Core.TestFramework { /// /// A certificate store that behaves like a hardware security token: it holds /// certificates whose private keys can be used but never extracted. /// /// /// Real tokens differ from each other in how they are addressed and how a /// session is opened, but they agree on the part that matters to the stack: /// the store hands back a certificate whose private key is a handle rather /// than key material, and refuses to give the key up. This store reproduces /// exactly that contract, in memory, on every platform, so the code paths a /// TPM, an HSM, a PKCS#11 token or a remote key service exercise can be /// covered by ordinary tests. /// /// Register it through CertificateManagerOptions.AddStoreProvider with /// , or construct it /// directly. Store paths use the simhw: scheme. /// /// public sealed class SimulatedHardwareCertificateStore : ICertificateStore { /// /// The store type name used to select this store. /// public const string StoreTypeName = "SimulatedHardware"; /// /// The store path prefix that identifies a simulated hardware store. /// public const string StoreScheme = "simhw:"; /// public string StoreType => StoreTypeName; /// public string StorePath => m_storePath; /// /// /// The store holds private keys, but they are never extractable. /// public bool NoPrivateKeys => false; /// public bool SupportsLoadPrivateKey => true; /// public bool SupportsCRLs => false; /// /// Gets the number of times a caller attempted to persist a private key. /// /// /// A token cannot accept key material it did not generate, so these /// attempts are recorded rather than performed. Tests assert on this to /// prove the stack does not silently rely on writing keys back. /// public int RejectedPrivateKeyWrites => m_rejectedPrivateKeyWrites; /// public void Open(string location, bool noPrivateKeys = true) { m_storePath = location ?? string.Empty; } /// public void Close() { } /// /// Generates a key pair inside the simulated token and stores a self /// signed certificate for it. /// /// The subject name of the certificate. /// The RSA key size. /// The stored certificate, including its non extractable key. /// /// The certificate is signed by the key that is about to become non /// extractable, which mirrors a token generating a key and issuing a self /// signed certificate for it before the key is locked down. /// public Certificate CreateRsaCertificate(string subjectName, int keySizeInBits = 2048) { RSA softwareKey = RSA.Create(keySizeInBits); var request = new CertificateRequest( subjectName, softwareKey, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); using X509Certificate2 selfSigned = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); using Certificate publicOnly = Certificate.FromRawData(selfSigned.RawData); Certificate stored = publicOnly.CopyWithDetachedPrivateKey( new NonExportableRsa(softwareKey, ownsKey: true)); Store(stored); return stored.AddRef(); } /// /// Generates an elliptic curve key pair inside the simulated token and /// stores a self signed certificate for it. /// /// The subject name of the certificate. /// The named curve to use. /// The stored certificate, including its non extractable key. public Certificate CreateEcdsaCertificate(string subjectName, ECCurve curve) { ECDsa softwareKey = ECDsa.Create(curve); var request = new CertificateRequest(subjectName, softwareKey, HashAlgorithmName.SHA256); using X509Certificate2 selfSigned = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); using Certificate publicOnly = Certificate.FromRawData(selfSigned.RawData); Certificate stored = publicOnly.CopyWithDetachedPrivateKey( new NonExportableECDsa(softwareKey, ownsKey: true)); Store(stored); return stored.AddRef(); } /// public Task EnumerateAsync(CancellationToken ct = default) { return Task.FromResult(Enumerate()); } private CertificateCollection Enumerate() { var results = new CertificateCollection(); lock (m_lock) { foreach (Certificate certificate in m_certificates.Values) { // Add takes its own reference, so this handle has to be released. using Certificate copy = Certificate.FromRawData(certificate.RawData); results.Add(copy); } } return results; } /// /// /// Only the public certificate is retained. A token cannot import key /// material it did not generate, so a private key offered here is /// recorded as rejected rather than stored. /// public Task AddAsync( Certificate certificate, char[]? password = null, CancellationToken ct = default) { if (certificate == null) { throw new ArgumentNullException(nameof(certificate)); } if (certificate.HasPrivateKey) { Interlocked.Increment(ref m_rejectedPrivateKeyWrites); } lock (m_lock) { // A token does not discard a key it generated because someone // handed it the matching public certificate. Only genuinely new // certificates are taken. if (m_certificates.TryGetValue(certificate.Thumbprint, out Certificate? existing) && existing.HasPrivateKey) { return Task.CompletedTask; } } Store(Certificate.FromRawData(certificate.RawData)); return Task.CompletedTask; } /// public Task AddRejectedAsync( CertificateCollection certificates, int maxCertificates, CancellationToken ct = default) { return Task.CompletedTask; } /// public Task DeleteAsync(string thumbprint, CancellationToken ct = default) { lock (m_lock) { if (m_certificates.TryGetValue(thumbprint, out Certificate? certificate)) { m_certificates.Remove(thumbprint); certificate.Dispose(); return Task.FromResult(true); } } return Task.FromResult(false); } /// public Task FindByThumbprintAsync( string thumbprint, CancellationToken ct = default) { return Task.FromResult(FindByThumbprint(thumbprint)); } private CertificateCollection FindByThumbprint(string thumbprint) { var results = new CertificateCollection(); lock (m_lock) { if (thumbprint != null && m_certificates.TryGetValue(thumbprint, out Certificate? certificate)) { // Add takes its own reference, so this handle has to be released. using Certificate copy = Certificate.FromRawData(certificate.RawData); results.Add(copy); } } return results; } /// /// /// Returns the certificate with its key handle attached in detached form. /// The key itself never leaves the simulated token. /// public Task LoadPrivateKeyAsync( string thumbprint, string? subjectName, string? applicationUri, NodeId certificateType, char[]? password, CancellationToken ct = default) { lock (m_lock) { foreach (Certificate certificate in m_certificates.Values) { if (!certificate.HasPrivateKey) { continue; } if (!string.IsNullOrEmpty(thumbprint) && !string.Equals(certificate.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)) { continue; } if (!string.IsNullOrEmpty(subjectName) && !X509Utils.CompareDistinguishedName(certificate.Subject, subjectName) && !certificate.Subject.Contains(subjectName, StringComparison.Ordinal)) { continue; } return Task.FromResult(certificate.AddRef()); } } return Task.FromResult(null); } /// public Task IsRevokedAsync( Certificate issuer, Certificate certificate, CancellationToken ct = default) { return Task.FromResult((StatusCode)StatusCodes.BadNotSupported); } /// public Task EnumerateCRLsAsync(CancellationToken ct = default) { return Task.FromResult(new X509CRLCollection()); } /// public Task EnumerateCRLsAsync( Certificate issuer, bool validateUpdateTime = true, CancellationToken ct = default) { return Task.FromResult(new X509CRLCollection()); } /// public Task AddCRLAsync(X509CRL crl, CancellationToken ct = default) { throw new NotSupportedException("The simulated hardware store does not support CRLs."); } /// public Task DeleteCRLAsync(X509CRL crl, CancellationToken ct = default) { throw new NotSupportedException("The simulated hardware store does not support CRLs."); } /// public void Dispose() { lock (m_lock) { foreach (Certificate certificate in m_certificates.Values) { certificate.Dispose(); } m_certificates.Clear(); } } private void Store(Certificate certificate) { lock (m_lock) { if (m_certificates.TryGetValue(certificate.Thumbprint, out Certificate? existing)) { existing.Dispose(); } m_certificates[certificate.Thumbprint] = certificate; } } private readonly Dictionary m_certificates = []; private readonly Lock m_lock = new(); private string m_storePath = string.Empty; private int m_rejectedPrivateKeyWrites; } /// /// Creates instances for the /// simhw: store scheme. /// /// /// Instances are cached per store path so that a certificate generated in the /// simulated token stays visible to every component that opens the same path, /// which is how a real token behaves. /// public sealed class SimulatedHardwareCertificateStoreProvider : ICertificateStoreProvider, IDisposable { /// public string StoreTypeName => SimulatedHardwareCertificateStore.StoreTypeName; /// public bool SupportsStorePath(string storePath) { return storePath != null && storePath.StartsWith( SimulatedHardwareCertificateStore.StoreScheme, StringComparison.OrdinalIgnoreCase); } /// public ICertificateStore CreateStore(ITelemetryContext telemetry) { return new SharedStoreHandle(this); } /// /// Returns the store backing a path, creating it on first use. /// /// The store path. /// The store for that path. public SimulatedHardwareCertificateStore GetStore(string storePath) { lock (m_lock) { if (!m_stores.TryGetValue(storePath, out SimulatedHardwareCertificateStore? store)) { store = new SimulatedHardwareCertificateStore(); store.Open(storePath, false); m_stores[storePath] = store; } return store; } } private readonly Dictionary m_stores = []; private readonly Lock m_lock = new(); /// /// Disposes every token this provider is holding open. /// /// /// The provider caches a store per path so a generated key stays visible, /// which means it - not the caller - owns the certificates those tokens /// hold. Without this they are never released and the certificate leak /// detector fails the run. /// public void Dispose() { lock (m_lock) { foreach (SimulatedHardwareCertificateStore store in m_stores.Values) { store.Dispose(); } m_stores.Clear(); } } /// /// Delegates to the shared store for whichever path it is opened with. /// /// /// The stack opens and disposes stores freely, so a handle must not take /// the backing token down with it. /// private sealed class SharedStoreHandle : ICertificateStore { public SharedStoreHandle(SimulatedHardwareCertificateStoreProvider owner) { m_owner = owner; } public string StoreType => SimulatedHardwareCertificateStore.StoreTypeName; public string StorePath => m_storePath; public bool NoPrivateKeys => false; public bool SupportsLoadPrivateKey => true; public bool SupportsCRLs => false; public void Open(string location, bool noPrivateKeys = true) { m_storePath = location ?? string.Empty; m_store = m_owner.GetStore(m_storePath); } public void Close() { } public Task EnumerateAsync(CancellationToken ct = default) => Store.EnumerateAsync(ct); public Task AddAsync(Certificate certificate, char[]? password = null, CancellationToken ct = default) => Store.AddAsync(certificate, password, ct); public Task AddRejectedAsync(CertificateCollection certificates, int maxCertificates, CancellationToken ct = default) => Store.AddRejectedAsync(certificates, maxCertificates, ct); public Task DeleteAsync(string thumbprint, CancellationToken ct = default) => Store.DeleteAsync(thumbprint, ct); public Task FindByThumbprintAsync(string thumbprint, CancellationToken ct = default) => Store.FindByThumbprintAsync(thumbprint, ct); public Task LoadPrivateKeyAsync( string thumbprint, string? subjectName, string? applicationUri, NodeId certificateType, char[]? password, CancellationToken ct = default) => Store.LoadPrivateKeyAsync( thumbprint, subjectName, applicationUri, certificateType, password, ct); public Task IsRevokedAsync(Certificate issuer, Certificate certificate, CancellationToken ct = default) => Store.IsRevokedAsync(issuer, certificate, ct); public Task EnumerateCRLsAsync(CancellationToken ct = default) => Store.EnumerateCRLsAsync(ct); public Task EnumerateCRLsAsync(Certificate issuer, bool validateUpdateTime = true, CancellationToken ct = default) => Store.EnumerateCRLsAsync(issuer, validateUpdateTime, ct); public Task AddCRLAsync(X509CRL crl, CancellationToken ct = default) => Store.AddCRLAsync(crl, ct); public Task DeleteCRLAsync(X509CRL crl, CancellationToken ct = default) => Store.DeleteCRLAsync(crl, ct); public void Dispose() { // The backing token outlives this handle by design. } private SimulatedHardwareCertificateStore Store => m_store ?? throw new InvalidOperationException("The store has not been opened."); private readonly SimulatedHardwareCertificateStoreProvider m_owner; // CA2213: the handle deliberately does not own the store. A real token // is not torn down because one consumer closed its session, and the // stack opens and disposes stores freely. The provider owns the store. #pragma warning disable CA2213 private SimulatedHardwareCertificateStore? m_store; #pragma warning restore CA2213 private string m_storePath = string.Empty; } } }