diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs new file mode 100644 index 00000000..fdf6ebc7 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Certificates/CertificateStoreManager.cs @@ -0,0 +1,122 @@ +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Certificates; + +/// Result of a certificate-store mutation; carries a friendly error instead of throwing. +public sealed record CertActionResult(bool Success, string? Error) +{ + /// A successful result. + public static CertActionResult Ok() => new(true, null); + /// A failed result carrying the given error message. + public static CertActionResult Fail(string error) => new(false, error); +} + +/// +/// 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 +/// certs/ on each validation, so changes are honored live without a restart. +/// +public sealed class CertificateStoreManager +{ + private static readonly string[] CertExtensions = [".der", ".cer", ".crt"]; + private static readonly HashSet MutableStores = + new(StringComparer.OrdinalIgnoreCase) { "trusted", "rejected" }; + + private readonly string _pkiRoot; + + /// Production ctor — reads OpcUa:PkiStoreRoot (default pki). + /// App configuration. + public CertificateStoreManager(IConfiguration config) + => _pkiRoot = config.GetValue("OpcUa:PkiStoreRoot") ?? "pki"; + + /// Test ctor — explicit PKI root. + /// The PKI store root directory. + internal CertificateStoreManager(string pkiRoot) => _pkiRoot = pkiRoot; + + /// Moves a rejected peer cert into the trusted store. + /// The cert thumbprint. + /// The action result. + public CertActionResult Trust(string thumbprint) => Move("rejected", "trusted", thumbprint); + + /// Moves a trusted peer cert back into the rejected store. + /// The cert thumbprint. + /// The action result. + public CertActionResult Untrust(string thumbprint) => Move("trusted", "rejected", thumbprint); + + /// Deletes a cert from the named mutable store (trusted or rejected). + /// The store name. + /// The cert thumbprint. + /// The action result. + 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); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs new file mode 100644 index 00000000..97b35ead --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Certificates/CertificateStoreManagerTests.cs @@ -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(); + } +}