From 39976772d9706b20722f5c619c1a44027594c79a Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 01:15:51 -0400 Subject: [PATCH] feat(site-runtime): CertStoreActor exports trusted certs (thumbprint+DER) for node reconciliation (UA1 groundwork) --- .../Messages/Management/CertTrustCommands.cs | 24 +++++++++++ .../Actors/CertStoreActor.cs | 42 +++++++++++++++++++ .../Actors/CertStoreActorTests.cs | 21 ++++++++++ 3 files changed, 87 insertions(+) diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs index 7f8dcf31..30b5e6dc 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/CertTrustCommands.cs @@ -82,6 +82,30 @@ public record RemoveCertFromLocalStore(string Thumbprint); /// Per-node: enumerate the local trusted-peer and rejected stores. public record ListLocalCerts(); +/// +/// Per-node: export the trusted-peer store contents (thumbprint + raw DER bytes) +/// so the Deployment Manager singleton can reconcile the trusted set onto a +/// (re)joining site node. Trusted store only — rejected certs are never pushed. +/// +public record ExportLocalTrustedCerts(); + +/// +/// One exported trusted certificate: its thumbprint (store filename key) and the +/// raw DER bytes needed to re-write it into another node's trusted-peer store. +/// +/// The certificate thumbprint. +/// The certificate's raw DER encoding. +public record ExportedCert(string Thumbprint, byte[] DerBytes); + +/// +/// Per-node reply to : the local trusted-peer +/// store's certificates, or a failure with the IO error. +/// +/// True if the trusted store was enumerated successfully. +/// The error message on failure, otherwise null. +/// Exported trusted certificates, empty on failure. +public record LocalCertExport(bool Success, string? Error, IReadOnlyList Certs); + /// /// Per-node ack for a , /// or operation. diff --git a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs index 96d9a3af..17f5739d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.SiteRuntime/Actors/CertStoreActor.cs @@ -51,6 +51,7 @@ public class CertStoreActor : ReceiveActor Receive(HandleWrite); Receive(HandleRemove); Receive(_ => HandleList()); + Receive(_ => HandleExport()); } /// @@ -133,6 +134,47 @@ public class CertStoreActor : ReceiveActor } } + /// + /// Exports the trusted-peer store (thumbprint + raw DER) so the Deployment + /// Manager singleton can reconcile the trusted set onto a (re)joining node. + /// Trusted store only — rejected certs are never pushed to peers. + /// + private void HandleExport() + { + try + { + var certs = new List(); + if (Directory.Exists(_trustedStoreDir)) + { + foreach (var file in Directory.EnumerateFiles(_trustedStoreDir) + .Where(f => f.EndsWith(".der", StringComparison.OrdinalIgnoreCase) + || f.EndsWith(".crt", StringComparison.OrdinalIgnoreCase))) + { + try + { + var der = File.ReadAllBytes(file); + // Load only to derive the canonical thumbprint (the store + // filename); the DER bytes shipped are the raw file bytes. + using var cert = X509CertificateLoader.LoadCertificate(der); + certs.Add(new ExportedCert(cert.Thumbprint, der)); + } + catch (Exception ex) + { + // A malformed file must not abort the whole export. + _log.Warning(ex, "Skipping unreadable certificate file {File} during export", file); + } + } + } + + Sender.Tell(new LocalCertExport(true, null, certs)); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to export trusted certificates from PKI store"); + Sender.Tell(new LocalCertExport(false, ex.Message, Array.Empty())); + } + } + private IEnumerable EnumerateStore(string storeDir, bool rejected) { if (!Directory.Exists(storeDir)) diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs index 7b429346..15ab81f9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Actors/CertStoreActorTests.cs @@ -106,6 +106,27 @@ public class CertStoreActorTests : TestKit, IDisposable emptyAck.Certs!.Should().NotContain(c => c.Thumbprint == thumbprint); } + [Fact] + public void ExportLocalTrustedCerts_ReturnsThumbprintAndDerBytes() + { + var (derBase64, thumbprint) = BuildSelfSignedCert(); + var expectedDer = Convert.FromBase64String(derBase64); + var actor = Sys.ActorOf(Props.Create(() => new CertStoreActor(_options))); + + // Seed one trusted cert via the normal write path. + actor.Tell(new WriteCertToLocalStore(derBase64, thumbprint)); + ExpectMsg().Success.Should().BeTrue(); + + // Export — thumbprint + raw DER bytes come back for reconciliation. + actor.Tell(new ExportLocalTrustedCerts()); + var export = ExpectMsg(); + export.Success.Should().BeTrue(); + export.Error.Should().BeNull(); + var entry = export.Certs.Should().ContainSingle().Subject; + entry.Thumbprint.Should().Be(thumbprint); + entry.DerBytes.Should().Equal(expectedDer); + } + [Fact] public void Write_InvalidBase64_ReturnsFailureAck() {