feat(adminui): CertificateStoreManager — by-thumbprint trust/untrust/delete

This commit is contained in:
Joseph Doherty
2026-06-18 05:04:36 -04:00
parent 77cc39e6a7
commit b47fc10ec0
2 changed files with 318 additions and 0 deletions
@@ -0,0 +1,122 @@
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
/// <summary>Result of a certificate-store mutation; carries a friendly error instead of throwing.</summary>
public sealed record CertActionResult(bool Success, string? Error)
{
/// <summary>A successful result.</summary>
public static CertActionResult Ok() => new(true, null);
/// <summary>A failed result carrying the given error message.</summary>
public static CertActionResult Fail(string error) => new(false, error);
}
/// <summary>
/// Trusts / untrusts / deletes peer certificates by moving or removing files in the
/// OPC UA server's PKI directory stores. The running server's DirectoryStore re-enumerates
/// <c>certs/</c> on each validation, so changes are honored live without a restart.
/// </summary>
public sealed class CertificateStoreManager
{
private static readonly string[] CertExtensions = [".der", ".cer", ".crt"];
private static readonly HashSet<string> MutableStores =
new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" };
private readonly string _pkiRoot;
/// <summary>Production ctor — reads <c>OpcUa:PkiStoreRoot</c> (default <c>pki</c>).</summary>
/// <param name="config">App configuration.</param>
public CertificateStoreManager(IConfiguration config)
=> _pkiRoot = config.GetValue<string?>("OpcUa:PkiStoreRoot") ?? "pki";
/// <summary>Test ctor — explicit PKI root.</summary>
/// <param name="pkiRoot">The PKI store root directory.</param>
internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot;
/// <summary>Moves a rejected peer cert into the trusted store.</summary>
/// <param name="thumbprint">The cert thumbprint.</param>
/// <returns>The action result.</returns>
public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint);
/// <summary>Moves a trusted peer cert back into the rejected store.</summary>
/// <param name="thumbprint">The cert thumbprint.</param>
/// <returns>The action result.</returns>
public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint);
/// <summary>Deletes a cert from the named mutable store (<c>trusted</c> or <c>rejected</c>).</summary>
/// <param name="store">The store name.</param>
/// <param name="thumbprint">The cert thumbprint.</param>
/// <returns>The action result.</returns>
public CertActionResult Delete(string store, string thumbprint)
{
if (!MutableStores.Contains(store)) return CertActionResult.Fail($"unknown store '{store}'");
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
try
{
var file = FindByThumbprint(CertsDir(store), thumbprint);
if (file is null) return CertActionResult.Fail($"certificate not found in {store}");
File.Delete(file);
return CertActionResult.Ok();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return CertActionResult.Fail(ex.Message);
}
}
private CertActionResult Move(string fromSub, string toSub, string thumbprint)
{
if (!IsValidThumbprint(thumbprint)) return CertActionResult.Fail("invalid thumbprint");
try
{
var src = FindByThumbprint(CertsDir(fromSub), thumbprint);
if (src is null) return CertActionResult.Fail($"certificate not found in {fromSub}");
var destDir = CertsDir(toSub);
Directory.CreateDirectory(destDir);
// Idempotent: if dest already holds this thumbprint, just drop the source.
if (FindByThumbprint(destDir, thumbprint) is not null)
{
File.Delete(src);
return CertActionResult.Ok();
}
var dest = Path.Combine(destDir, Path.GetFileName(src));
if (File.Exists(dest))
dest = Path.Combine(destDir,
$"{Path.GetFileNameWithoutExtension(src)}_{thumbprint}{Path.GetExtension(src)}");
File.Move(src, dest);
return CertActionResult.Ok();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
return CertActionResult.Fail(ex.Message);
}
}
private string CertsDir(string sub) => Path.Combine(_pkiRoot, sub, "certs");
private static string? FindByThumbprint(string certsDir, string thumbprint)
{
if (!Directory.Exists(certsDir)) return null;
foreach (var file in Directory.EnumerateFiles(certsDir))
{
if (!CertExtensions.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
try
{
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
return file;
}
catch { /* ignore unreadable entries */ }
}
return null;
}
private static bool IsValidThumbprint(string thumbprint) =>
!string.IsNullOrEmpty(thumbprint)
&& (thumbprint.Length == 40 || thumbprint.Length == 64)
&& thumbprint.All(Uri.IsHexDigit);
}