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();
+ }
+}