using System.Security.Cryptography.X509Certificates; using Microsoft.Extensions.Options; namespace ZB.MOM.WW.OtOpcUa.Admin.Services; /// /// Metadata for a certificate file found in one of the OPC UA server's PKI stores. The /// is the absolute path of the DER/CRT file the stack created when it /// rejected the cert (for ) or when an operator trusted /// it (for ). /// public sealed record CertInfo( string Thumbprint, string Subject, string Issuer, DateTime NotBefore, DateTime NotAfter, string FilePath, CertStoreKind Store); public enum CertStoreKind { Rejected, Trusted, } /// /// Filesystem-backed view over the OPC UA server's PKI store. The Opc.Ua stack uses a /// Directory-typed store — each cert is a .der file under {root}/{store}/certs/ /// with a filename derived from subject + thumbprint. This service exposes operators for the /// Admin UI: list rejected, list trusted, trust a rejected cert (move to trusted), remove a /// rejected cert (delete), untrust a previously trusted cert (delete from trusted). /// /// /// The Admin process is separate from the Server process; this service deliberately has no /// Opc.Ua dependency — it works on the on-disk layout directly so it can run on the Admin /// host even when the Server isn't installed locally, as long as the PKI root is reachable /// (typical deployment has Admin + Server side-by-side on the same machine). /// /// Trust/untrust requires the Server to re-read its trust list. The Opc.Ua stack re-reads /// the Directory store on each new incoming connection, so there's no explicit signal /// needed — the next client handshake picks up the change. Operators should retry the /// rejected client's connection after trusting. /// public sealed class CertTrustService { private readonly CertTrustOptions _options; private readonly ILogger _logger; public CertTrustService(IOptions options, ILogger logger) { _options = options.Value; _logger = logger; } public string PkiStoreRoot => _options.PkiStoreRoot; public IReadOnlyList ListRejected() => ListStore(CertStoreKind.Rejected); public IReadOnlyList ListTrusted() => ListStore(CertStoreKind.Trusted); /// /// Move the cert with from the rejected store to the /// trusted store. No-op returns false if the rejected file doesn't exist (already moved /// by another operator, or thumbprint mismatch). Overwrites an existing trusted copy /// silently — idempotent. /// public bool TrustRejected(string thumbprint) { var cert = FindInStore(CertStoreKind.Rejected, thumbprint); if (cert is null) return false; var trustedDir = CertsDir(CertStoreKind.Trusted); Directory.CreateDirectory(trustedDir); var destPath = Path.Combine(trustedDir, Path.GetFileName(cert.FilePath)); File.Move(cert.FilePath, destPath, overwrite: true); _logger.LogInformation("Trusted cert {Thumbprint} (subject={Subject}) — moved {From} → {To}", cert.Thumbprint, cert.Subject, cert.FilePath, destPath); return true; } public bool DeleteRejected(string thumbprint) => DeleteFromStore(CertStoreKind.Rejected, thumbprint); public bool UntrustCert(string thumbprint) => DeleteFromStore(CertStoreKind.Trusted, thumbprint); private bool DeleteFromStore(CertStoreKind store, string thumbprint) { var cert = FindInStore(store, thumbprint); if (cert is null) return false; File.Delete(cert.FilePath); _logger.LogInformation("Deleted cert {Thumbprint} (subject={Subject}) from {Store} store", cert.Thumbprint, cert.Subject, store); return true; } private CertInfo? FindInStore(CertStoreKind store, string thumbprint) => ListStore(store).FirstOrDefault(c => string.Equals(c.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase)); private IReadOnlyList ListStore(CertStoreKind store) { var dir = CertsDir(store); if (!Directory.Exists(dir)) return []; var results = new List(); foreach (var path in Directory.EnumerateFiles(dir)) { // Skip CRL sidecars + private-key files — trust operations only concern public certs. var ext = Path.GetExtension(path); if (!ext.Equals(".der", StringComparison.OrdinalIgnoreCase) && !ext.Equals(".crt", StringComparison.OrdinalIgnoreCase) && !ext.Equals(".cer", StringComparison.OrdinalIgnoreCase)) { continue; } try { var cert = X509CertificateLoader.LoadCertificateFromFile(path); results.Add(new CertInfo( cert.Thumbprint, cert.Subject, cert.Issuer, cert.NotBefore.ToUniversalTime(), cert.NotAfter.ToUniversalTime(), path, store)); } catch (Exception ex) { // A malformed file in the store shouldn't take down the page. Surface it in logs // but skip — operators see the other certs and can clean the bad file manually. _logger.LogWarning(ex, "Failed to parse cert at {Path} — skipping", path); } } return results; } private string CertsDir(CertStoreKind store) => Path.Combine(_options.PkiStoreRoot, store == CertStoreKind.Rejected ? "rejected" : "trusted", "certs"); }