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) { if (!IsSafeThumbprint(msg.Thumbprint)) { _log.Warning("Rejecting write for invalid thumbprint {Thumbprint}", msg.Thumbprint); Sender.Tell(new LocalCertOpAck(false, "invalid thumbprint", null)); return; } 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) { if (!IsSafeThumbprint(msg.Thumbprint)) { _log.Warning("Rejecting remove for invalid thumbprint {Thumbprint}", msg.Thumbprint); Sender.Tell(new LocalCertOpAck(false, "invalid thumbprint", null)); return; } 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 { // The TrustedCertInfo projection copies all needed strings/dates // out before this scope ends, so disposing the cert is safe and // releases the native handle held per loaded certificate. using 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"; /// /// Defense-in-depth path-traversal guard. In production a thumbprint is a /// hex SHA1 (always safe), but RemoveServerCertCommand is CLI-exposed /// and this actor accepts the string unchecked, so a thumbprint such as /// ../../etc/foo would otherwise resolve OUTSIDE the trusted-store /// directory once combined into a .der file name. Rejects empty, /// separator-bearing, or dot-dot thumbprints before any filesystem touch. /// private static bool IsSafeThumbprint(string thumbprint) => !string.IsNullOrWhiteSpace(thumbprint) && thumbprint.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && !thumbprint.Contains("..", StringComparison.Ordinal); }