feat(adminui): CertificateStoreManager — by-thumbprint trust/untrust/delete
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Security.Cryptography.X509Certificates;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Certificates;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Certificates;
|
||||||
|
|
||||||
|
public sealed class CertificateStoreManagerTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _root;
|
||||||
|
private readonly CertificateStoreManager _sut;
|
||||||
|
|
||||||
|
public CertificateStoreManagerTests()
|
||||||
|
{
|
||||||
|
_root = Path.Combine(Path.GetTempPath(), $"pki-test-{Guid.NewGuid():N}");
|
||||||
|
_sut = new CertificateStoreManager(_root);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
Directory.Delete(_root, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Seed helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string SeedCert(string storeCertsDir, string cn)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(storeCertsDir);
|
||||||
|
using var rsa = RSA.Create(2048);
|
||||||
|
var req = new CertificateRequest(
|
||||||
|
$"CN={cn}", rsa, HashAlgorithmName.SHA256,
|
||||||
|
RSASignaturePadding.Pkcs1);
|
||||||
|
using var cert = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1));
|
||||||
|
var file = Path.Combine(storeCertsDir, $"{cn} [{cert.Thumbprint}].der");
|
||||||
|
File.WriteAllBytes(file, cert.Export(X509ContentType.Cert));
|
||||||
|
return cert.Thumbprint!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assertion helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static bool ThumbprintExistsInDir(string certsDir, string thumbprint)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(certsDir)) return false;
|
||||||
|
string[] exts = [".der", ".cer", ".crt"];
|
||||||
|
foreach (var file in Directory.EnumerateFiles(certsDir))
|
||||||
|
{
|
||||||
|
if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
|
||||||
|
if (string.Equals(cert.Thumbprint, thumbprint, StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CertCountInDir(string certsDir)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(certsDir)) return 0;
|
||||||
|
string[] exts = [".der", ".cer", ".crt"];
|
||||||
|
int count = 0;
|
||||||
|
foreach (var file in Directory.EnumerateFiles(certsDir))
|
||||||
|
{
|
||||||
|
if (!exts.Contains(Path.GetExtension(file), StringComparer.OrdinalIgnoreCase)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var cert = X509CertificateLoader.LoadCertificateFromFile(file);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trust_MovesRejectedToTrusted()
|
||||||
|
{
|
||||||
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
||||||
|
var tp = SeedCert(rejectedCerts, "TestClient1");
|
||||||
|
|
||||||
|
var result = _sut.Trust(tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should have left rejected");
|
||||||
|
ThumbprintExistsInDir(Path.Combine(_root, "trusted", "certs"), tp).ShouldBeTrue("cert should be in trusted");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Untrust_MovesTrustedToRejected()
|
||||||
|
{
|
||||||
|
var trustedCerts = Path.Combine(_root, "trusted", "certs");
|
||||||
|
var tp = SeedCert(trustedCerts, "TestClient2");
|
||||||
|
|
||||||
|
var result = _sut.Untrust(tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should have left trusted");
|
||||||
|
ThumbprintExistsInDir(Path.Combine(_root, "rejected", "certs"), tp).ShouldBeTrue("cert should be in rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Delete_Rejected_RemovesFile()
|
||||||
|
{
|
||||||
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
||||||
|
var tp = SeedCert(rejectedCerts, "TestClient3");
|
||||||
|
|
||||||
|
var result = _sut.Delete("rejected", tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("cert should be gone from rejected");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Delete_Trusted_RemovesFile()
|
||||||
|
{
|
||||||
|
var trustedCerts = Path.Combine(_root, "trusted", "certs");
|
||||||
|
var tp = SeedCert(trustedCerts, "TestClient4");
|
||||||
|
|
||||||
|
var result = _sut.Delete("trusted", tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
ThumbprintExistsInDir(trustedCerts, tp).ShouldBeFalse("cert should be gone from trusted");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trust_UnknownThumbprint_FailsNoChange()
|
||||||
|
{
|
||||||
|
var tp = new string('A', 40);
|
||||||
|
|
||||||
|
var result = _sut.Trust(tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
CertCountInDir(Path.Combine(_root, "trusted", "certs")).ShouldBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trust_PathTraversalThumbprint_Rejected()
|
||||||
|
{
|
||||||
|
var result = _sut.Trust("../../x");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error.ShouldContain("invalid", Case.Insensitive);
|
||||||
|
// Verify no files were created anywhere under root
|
||||||
|
if (Directory.Exists(_root))
|
||||||
|
Directory.GetFiles(_root, "*", SearchOption.AllDirectories).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Delete_DisallowedStore_Fails()
|
||||||
|
{
|
||||||
|
var ownCerts = Path.Combine(_root, "own", "certs");
|
||||||
|
var tp = SeedCert(ownCerts, "OwnCert");
|
||||||
|
|
||||||
|
var result = _sut.Delete("own", tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
result.Error.ShouldContain("store", Case.Insensitive);
|
||||||
|
ThumbprintExistsInDir(ownCerts, tp).ShouldBeTrue("own cert should still be present");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trust_AlreadyInDest_IdempotentSuccess()
|
||||||
|
{
|
||||||
|
var rejectedCerts = Path.Combine(_root, "rejected", "certs");
|
||||||
|
var trustedCerts = Path.Combine(_root, "trusted", "certs");
|
||||||
|
|
||||||
|
// Seed into rejected, then copy that exact file into trusted so both have identical bytes/thumbprint
|
||||||
|
var tp = SeedCert(rejectedCerts, "TestClient8");
|
||||||
|
Directory.CreateDirectory(trustedCerts);
|
||||||
|
var srcFile = Directory.GetFiles(rejectedCerts).Single();
|
||||||
|
File.Copy(srcFile, Path.Combine(trustedCerts, Path.GetFileName(srcFile)));
|
||||||
|
|
||||||
|
var result = _sut.Trust(tp);
|
||||||
|
|
||||||
|
result.Success.ShouldBeTrue(result.Error);
|
||||||
|
ThumbprintExistsInDir(rejectedCerts, tp).ShouldBeFalse("source (rejected) should no longer have it");
|
||||||
|
CertCountInDir(trustedCerts).ShouldBe(1, "trusted should still have exactly one copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Trust_NullOrEmptyThumbprint_Fails()
|
||||||
|
{
|
||||||
|
var result = _sut.Trust("");
|
||||||
|
|
||||||
|
result.Success.ShouldBeFalse();
|
||||||
|
result.Error.ShouldNotBeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user