From c8d9303031ebd7375cea407b057881d4ff4c7c9a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 03:13:48 -0400 Subject: [PATCH] feat(siteruntime): per-node CertStore actor + trust broadcast to both site nodes (T17) --- .../Messages/Management/CertTrustCommands.cs | 92 ++++++++++ .../Actors/AkkaHostedService.cs | 15 ++ .../Actors/CertStoreActor.cs | 159 ++++++++++++++++++ .../Actors/DeploymentManagerActor.cs | 124 ++++++++++++++ .../ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj | 7 + .../Actors/CertStoreActorTests.cs | 129 ++++++++++++++ 6 files changed, 526 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs create mode 100644 src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs new file mode 100644 index 00000000..10b15f95 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs @@ -0,0 +1,92 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +// ───────────────────────────────────────────────────────────────────────────── +// T17 / D6 — OPC UA server-certificate trust management. +// +// Cert trust is SITE-LOCAL: there is no central persistence of trusted server +// certificates. The trusted-peer PKI store lives on each site node's file +// system, so a trust/remove decision must reach BOTH site nodes (node-a and +// node-b) or the two PKI stores diverge across failover (Decision D6). +// +// The public commands below are handled by the site Deployment Manager +// singleton (active node only). For trust/remove it broadcasts the +// corresponding per-node internal message to the per-node CertStoreActor on +// EVERY site cluster node; for list it answers from the local node. +// ───────────────────────────────────────────────────────────────────────────── + +/// +/// Trust an OPC UA server certificate at every site node. Carries the raw +/// DER bytes (base64-encoded) so the certificate can be written into each +/// node's trusted-peer PKI store. The thumbprint is the store filename key. +/// +/// The data-connection the certificate was captured from (diagnostics / correlation only). +/// The server certificate's DER encoding, base64-encoded. +/// The certificate thumbprint — used as the store filename key. +public record TrustServerCertCommand(string ConnectionName, string DerBase64, string Thumbprint); + +/// +/// Remove a previously-trusted OPC UA server certificate from every site +/// node's trusted-peer PKI store, identified by thumbprint. +/// +/// The thumbprint of the certificate to remove. +public record RemoveServerCertCommand(string Thumbprint); + +/// +/// List the certificates currently present in this site's trusted-peer and +/// rejected PKI stores. Answered from the local node (the Deployment Manager +/// singleton's own node). +/// +public record ListServerCertsCommand(); + +/// +/// Read-only projection of a certificate found in a site PKI store. +/// +/// The certificate thumbprint. +/// The certificate subject distinguished name. +/// The certificate issuer distinguished name. +/// Validity start (UTC). +/// Validity end (UTC). +/// True if the certificate is in the rejected store; false if trusted. +public record TrustedCertInfo( + string Thumbprint, + string Subject, + string Issuer, + DateTime NotBeforeUtc, + DateTime NotAfterUtc, + bool Rejected); + +/// +/// Aggregate result of a cert-trust command. For trust/remove, +/// reflects whether every reachable site node acked; carries the +/// first node error (or a partial-failure note when a node did not ack in time). +/// For list, carries the local node's store contents. +/// +/// True when the operation succeeded on all targeted nodes. +/// First error encountered, or null on success. +/// Listed certificates (list command only), otherwise null. +public record CertTrustResult(bool Success, string? Error, IReadOnlyList? Certs); + +// ── Per-node internal messages (CertStoreActor wire protocol) ── +// Sent by the Deployment Manager singleton to each site node's CertStoreActor, +// or used directly in tests. Not part of the public management surface. + +/// Per-node: decode and write it into the local trusted-peer store as <Thumbprint>.der. +/// The certificate's DER encoding, base64-encoded. +/// The thumbprint used as the store filename key. +public record WriteCertToLocalStore(string DerBase64, string Thumbprint); + +/// Per-node: delete the trusted-peer store file matching . +/// The thumbprint of the certificate to remove. +public record RemoveCertFromLocalStore(string Thumbprint); + +/// Per-node: enumerate the local trusted-peer and rejected stores. +public record ListLocalCerts(); + +/// +/// Per-node ack for a , +/// or operation. +/// +/// True if the local store operation succeeded. +/// The error message on failure, otherwise null. +/// Listed certificates (list only), otherwise null. +public record LocalCertOpAck(bool Success, string? Error, IReadOnlyList? Certs); diff --git a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs index 76f5b5fa..0d3aa801 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Host/Actors/AkkaHostedService.cs @@ -800,6 +800,21 @@ akka {{ _logger.LogInformation("Data Connection Layer manager actor created"); } + // T17 / D6 — per-node OPC UA certificate-store actor. Created on EVERY + // site node (NOT a singleton) at a well-known name so the Deployment + // Manager singleton can fan a trust/remove out to BOTH nodes' PKI stores + // (node-a + node-b) and keep them in lock-step across failover. It needs + // the same deployment-wide OpcUaGlobalOptions the DCL manager uses so a + // trusted cert lands in the exact store RealOpcUaClient validates against. + var certStoreOpcUaOptions = _serviceProvider + .GetService>()?.Value + ?? new ZB.MOM.WW.ScadaBridge.DataConnectionLayer.OpcUaGlobalOptions(); + _actorSystem!.ActorOf( + Props.Create(() => new ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor(certStoreOpcUaOptions)), + ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor.WellKnownName); + _logger.LogInformation("Per-node CertStoreActor created at well-known name '{Name}' (T17/D6)", + ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors.CertStoreActor.WellKnownName); + // Resolve the health collector for the Deployment Manager var siteHealthCollector = _serviceProvider.GetService(); siteHealthCollector?.SetNodeHostname(_nodeOptions.NodeHostname); diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs new file mode 100644 index 00000000..19b60bca --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs @@ -0,0 +1,159 @@ +using System.Security.Cryptography.X509Certificates; +using Akka.Actor; +using Akka.Event; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; + +/// +/// Per-node OPC UA certificate-store actor (T17 / D6). Runs on EVERY site +/// node (NOT a singleton) at a well-known name so the Deployment Manager +/// singleton can address it on each node via . +/// +/// Cert trust is site-local: the trusted-peer PKI store is a directory on each +/// node's file system. A trust/remove decision must therefore reach BOTH site +/// nodes or the two stores diverge across failover (D6). The singleton fans the +/// per-node / +/// message out; this actor performs the actual file write/delete/enumerate. +/// +/// NOTE on the script-trust forbidden-IO rule: that rule applies only to USER +/// scripts. This is a framework/DCL actor doing legitimate PKI store file I/O, +/// exactly as the underlying OPC UA stack does when it persists rejected certs. +/// +public class CertStoreActor : ReceiveActor +{ + /// + /// Well-known actor name. The Deployment Manager singleton addresses each + /// site node's instance via {member.Address}/user/{WellKnownName}. + /// + public const string WellKnownName = "cert-store"; + + private readonly ILoggingAdapter _log = Context.GetLogger(); + private readonly string _trustedStoreDir; + private readonly string _rejectedStoreDir; + + /// + /// Initializes the actor with the deployment-wide OPC UA options used to + /// resolve the trusted-peer and rejected store directories. + /// + /// Deployment-wide OPC UA options (store paths). + public CertStoreActor(OpcUaGlobalOptions opcUaGlobalOptions) + { + // Resolve store directories with the SAME fallback logic + // RealOpcUaClient.ResolveStorePath uses, so a cert trusted here lands in + // the exact store the OPC UA client validates against. That helper is a + // private static method on RealOpcUaClient; the tiny logic is replicated + // here rather than widening its visibility. + _trustedStoreDir = ResolveStorePath(opcUaGlobalOptions.TrustedPeerStorePath, "trusted"); + _rejectedStoreDir = ResolveStorePath(opcUaGlobalOptions.RejectedCertificateStorePath, "rejected"); + + Receive(HandleWrite); + Receive(HandleRemove); + Receive(_ => HandleList()); + } + + /// + /// Replicates RealOpcUaClient.ResolveStorePath: an empty configured + /// path falls back to %TEMP%/ScadaBridge/pki/<leaf>. + /// + private static string ResolveStorePath(string configured, string fallbackLeaf) => + string.IsNullOrWhiteSpace(configured) + ? Path.Combine(Path.GetTempPath(), "ScadaBridge", "pki", fallbackLeaf) + : configured; + + private void HandleWrite(WriteCertToLocalStore msg) + { + try + { + Directory.CreateDirectory(_trustedStoreDir); + var der = Convert.FromBase64String(msg.DerBase64); + var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint)); + File.WriteAllBytes(path, der); + _log.Info("Trusted server certificate {Thumbprint} written to {Path}", msg.Thumbprint, path); + Sender.Tell(new LocalCertOpAck(true, null, null)); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to write trusted server certificate {Thumbprint}", msg.Thumbprint); + Sender.Tell(new LocalCertOpAck(false, ex.Message, null)); + } + } + + private void HandleRemove(RemoveCertFromLocalStore msg) + { + try + { + var path = Path.Combine(_trustedStoreDir, FileNameFor(msg.Thumbprint)); + if (File.Exists(path)) + { + File.Delete(path); + _log.Info("Trusted server certificate {Thumbprint} removed from {Path}", msg.Thumbprint, path); + } + else + { + _log.Info("Trusted server certificate {Thumbprint} not present; remove is a no-op", msg.Thumbprint); + } + Sender.Tell(new LocalCertOpAck(true, null, null)); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to remove trusted server certificate {Thumbprint}", msg.Thumbprint); + Sender.Tell(new LocalCertOpAck(false, ex.Message, null)); + } + } + + private void HandleList() + { + try + { + var certs = new List(); + certs.AddRange(EnumerateStore(_trustedStoreDir, rejected: false)); + certs.AddRange(EnumerateStore(_rejectedStoreDir, rejected: true)); + Sender.Tell(new LocalCertOpAck(true, null, certs)); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to list certificates from PKI stores"); + Sender.Tell(new LocalCertOpAck(false, ex.Message, null)); + } + } + + private IEnumerable EnumerateStore(string storeDir, bool rejected) + { + if (!Directory.Exists(storeDir)) + { + yield break; + } + + foreach (var file in Directory.EnumerateFiles(storeDir) + .Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase) + || f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase))) + { + TrustedCertInfo? info = null; + try + { + var cert = X509CertificateLoader.LoadCertificate(File.ReadAllBytes(file)); + info = new TrustedCertInfo( + cert.Thumbprint, + cert.Subject, + cert.Issuer, + cert.NotBefore.ToUniversalTime(), + cert.NotAfter.ToUniversalTime(), + rejected); + } + catch (Exception ex) + { + // A malformed file in the store should not abort the whole listing. + _log.Warning(ex, "Skipping unreadable certificate file {File}", file); + } + + if (info is not null) + { + yield return info; + } + } + } + + private static string FileNameFor(string thumbprint) => $"{thumbprint}.der"; +} diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs index c33b6360..866bb507 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/DeploymentManagerActor.cs @@ -1,4 +1,5 @@ using Akka.Actor; +using Akka.Cluster; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; @@ -167,6 +168,15 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers Receive(msg => Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender)); + // T17 / D6 — OPC UA server-certificate trust. Trust is site-local: the + // trusted-peer PKI store is per-node, so a trust/remove MUST reach BOTH + // site nodes (node-a + node-b) or they diverge across failover. This + // singleton fans the corresponding per-node message out to the + // CertStoreActor on EVERY site node; list is answered from this node. + Receive(HandleTrustServerCert); + Receive(HandleRemoveServerCert); + Receive(HandleListServerCerts); + // Internal startup messages Receive(HandleStartupConfigsLoaded); Receive(HandleSharedScriptsLoaded); @@ -701,6 +711,120 @@ public class DeploymentManagerActor : ReceiveActor, IWithTimers }).PipeTo(sender); } + // ── T17 / D6 — OPC UA server-certificate trust ── + + /// + /// The base cluster role every site node carries (in addition to its + /// per-site role site-{SiteId}). Used to enumerate the site nodes a + /// trust/remove must reach so node-a and node-b PKI stores stay in lock-step + /// across failover (D6). Matches the role string set in NodeOptions.Role + /// for site hosts (see AkkaHostedService.BuildRoles). + /// + private const string SiteClusterRole = "Site"; + + /// + /// The per-node ask timeout for a cert broadcast. A node that does not ack + /// within this window is reported as a partial failure (the command is not + /// failed wholesale). + /// + private static readonly TimeSpan CertBroadcastTimeout = TimeSpan.FromSeconds(5); + + /// + /// T17 / D6: broadcasts a trust to the per-node + /// on every Up site node so both PKI stores receive the certificate. + /// + private void HandleTrustServerCert(TrustServerCertCommand command) => + BroadcastToSiteCertStores( + new WriteCertToLocalStore(command.DerBase64, command.Thumbprint), + $"trust cert {command.Thumbprint} (connection {command.ConnectionName})"); + + /// + /// T17 / D6: broadcasts a remove to the per-node + /// on every Up site node so the certificate leaves both PKI stores. + /// + private void HandleRemoveServerCert(RemoveServerCertCommand command) => + BroadcastToSiteCertStores( + new RemoveCertFromLocalStore(command.Thumbprint), + $"remove cert {command.Thumbprint}"); + + /// + /// T17: lists this node's trusted + rejected PKI stores by asking the LOCAL + /// (the singleton's own node). The list reflects + /// the active node's view; a trust broadcast keeps the standby in sync. + /// + private void HandleListServerCerts(ListServerCertsCommand command) + { + var sender = Sender; + var local = Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}"); + local.Ask(new ListLocalCerts(), CertBroadcastTimeout) + .ContinueWith(t => + { + if (t.IsCompletedSuccessfully) + { + var ack = t.Result; + return new CertTrustResult(ack.Success, ack.Error, ack.Certs); + } + + var err = t.Exception?.GetBaseException().Message ?? "cert store did not respond"; + _logger.LogWarning("Local cert store list failed: {Error}", err); + return new CertTrustResult(false, err, null); + }).PipeTo(sender); + } + + /// + /// Fans out to the + /// on every Up site node, asks each with a short timeout, and aggregates the + /// acks into one . A node that fails to ack is a + /// partial failure (Success=false, first error reported) — it never throws, + /// so a single unreachable standby cannot stall the singleton. + /// + private void BroadcastToSiteCertStores(object localMessage, string description) + { + var sender = Sender; + var cluster = Cluster.Get(Context.System); + var targets = cluster.State.Members + .Where(m => m.Status == MemberStatus.Up && m.HasRole(SiteClusterRole)) + .Select(m => m.Address) + .ToList(); + + if (targets.Count == 0) + { + _logger.LogWarning("No Up site nodes found to {Description}; nothing trusted", description); + sender.Tell(new CertTrustResult(false, "no site nodes available", null)); + return; + } + + _logger.LogInformation("Broadcasting cert op to {Count} site node(s): {Description}", + targets.Count, description); + + var asks = targets.Select(address => + { + var path = new RootActorPath(address) / "user" / CertStoreActor.WellKnownName; + return Context.ActorSelection(path) + .Ask(localMessage, CertBroadcastTimeout) + .ContinueWith(t => t.IsCompletedSuccessfully + ? t.Result + : new LocalCertOpAck(false, + $"node {address} did not ack: {t.Exception?.GetBaseException().Message}", + null)); + }).ToArray(); + + Task.WhenAll(asks).ContinueWith(t => + { + // ContinueWith on WhenAll over per-task error-trapping completions + // never faults, so t.Result is always populated. + var acks = t.Result; + var allSucceeded = acks.All(a => a.Success); + var firstError = acks.FirstOrDefault(a => !a.Success)?.Error; + if (!allSucceeded) + { + _logger.LogWarning("Cert broadcast partial/total failure ({Description}): {Error}", + description, firstError); + } + return new CertTrustResult(allSucceeded, firstError, null); + }).PipeTo(sender); + } + // ── DCL connection management ── /// diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj index ce9bce60..0ffcb370 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/ZB.MOM.WW.ScadaBridge.SiteRuntime.csproj @@ -35,6 +35,13 @@ + + diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs new file mode 100644 index 00000000..216418ce --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Akka.Actor; +using Akka.TestKit.Xunit2; +using FluentAssertions; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.DataConnectionLayer; +using ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors; + +namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Actors; + +/// +/// T17 / D6: per-node CertStoreActor write/list/remove round-trip against a +/// temp trusted-peer PKI store. The cluster broadcast (singleton → every node's +/// CertStoreActor) is validated live at integration E1; here we cover the +/// testable per-node core. +/// +public class CertStoreActorTests : TestKit, IDisposable +{ + private readonly string _trustedDir; + private readonly string _rejectedDir; + private readonly OpcUaGlobalOptions _options; + + public CertStoreActorTests() + { + var root = Path.Combine(Path.GetTempPath(), "ScadaBridge-CertStoreTests", Guid.NewGuid().ToString("N")); + _trustedDir = Path.Combine(root, "trusted"); + _rejectedDir = Path.Combine(root, "rejected"); + _options = new OpcUaGlobalOptions + { + TrustedPeerStorePath = _trustedDir, + RejectedCertificateStorePath = _rejectedDir, + }; + } + + void IDisposable.Dispose() + { + Shutdown(); + TryDeleteDir(Path.GetDirectoryName(_trustedDir)!); + } + + private static void TryDeleteDir(string dir) + { + try + { + if (Directory.Exists(dir)) + { + Directory.Delete(dir, recursive: true); + } + } + catch + { + // Best-effort temp cleanup. + } + } + + private static (string DerBase64, string Thumbprint) BuildSelfSignedCert() + { + using var rsa = RSA.Create(2048); + var req = new CertificateRequest( + "CN=ScadaBridge-CertStoreTest", + rsa, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + using var cert = req.CreateSelfSigned( + DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(365)); + var der = cert.Export(X509ContentType.Cert); + return (Convert.ToBase64String(der), cert.Thumbprint); + } + + [Fact] + public void Write_ThenList_ThenRemove_RoundTrips() + { + var (derBase64, thumbprint) = BuildSelfSignedCert(); + var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); + + // Write + actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint)); + var writeAck = ExpectMsg(); + writeAck.Success.Should().BeTrue(); + writeAck.Error.Should().BeNull(); + + var expectedFile = Path.Combine(_trustedDir, $"{thumbprint}.der"); + File.Exists(expectedFile).Should().BeTrue("the cert is written into the trusted-peer store"); + + // List — the cert appears, not rejected + actor.Tell(new ListLocalCerts()); + var listAck = ExpectMsg(); + listAck.Success.Should().BeTrue(); + listAck.Certs.Should().NotBeNull(); + listAck.Certs!.Should().ContainSingle(c => c.Thumbprint == thumbprint) + .Which.Rejected.Should().BeFalse(); + + // Remove — ack + file gone + actor.Tell(new RemoveCertFromLocalStore(thumbprint)); + var removeAck = ExpectMsg(); + removeAck.Success.Should().BeTrue(); + File.Exists(expectedFile).Should().BeFalse("the cert file is deleted from the trusted-peer store"); + + // List again — empty + actor.Tell(new ListLocalCerts()); + var emptyAck = ExpectMsg(); + emptyAck.Success.Should().BeTrue(); + emptyAck.Certs.Should().NotBeNull(); + emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint); + } + + [Fact] + public void Write_InvalidBase64_ReturnsFailureAck() + { + var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); + + actor.Tell(new WriteCertToLocalStore("not-valid-base64!!", "ABC123")); + var ack = ExpectMsg(); + ack.Success.Should().BeFalse(); + ack.Error.Should().NotBeNullOrEmpty(); + } + + [Fact] + public void Remove_MissingCert_IsNoOpSuccess() + { + var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); + + actor.Tell(new RemoveCertFromLocalStore("NONEXISTENTTHUMBPRINT")); + var ack = ExpectMsg(); + ack.Success.Should().BeTrue("removing an absent cert is an idempotent no-op"); + } +}